mixdog 0.9.93 → 0.9.94

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/rules/agent/30-explorer.md +22 -16
  3. package/src/rules/shared/01-tool.md +23 -18
  4. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +22 -6
  5. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  6. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  7. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  8. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +100 -86
  9. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  10. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  11. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +3 -3
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -4
  14. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  15. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  18. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  19. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +6 -1
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  23. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  24. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +543 -19
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +5 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  28. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  30. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +4 -2
  31. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +51 -2
  32. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  33. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  34. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +3 -3
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +3 -3
  36. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  37. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  38. package/src/runtime/shared/background-tasks.mjs +10 -3
  39. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  40. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  41. package/src/runtime/shared/tool-card-model.mjs +6 -2
  42. package/src/runtime/shared/tool-surface.mjs +7 -2
  43. package/src/session-runtime/provider-usage.mjs +26 -2
  44. package/src/standalone/explore-tool.mjs +1 -1
  45. package/src/tui/app/use-transcript-window.mjs +7 -1
  46. package/src/tui/components/Spinner.jsx +18 -9
  47. package/src/tui/dist/index.mjs +111 -25
  48. package/src/tui/engine/live-share.mjs +23 -3
  49. package/src/tui/engine/session-api.mjs +7 -0
  50. package/src/tui/engine/turn.mjs +77 -4
  51. package/src/tui/engine.mjs +14 -6
  52. package/src/tui/index.jsx +7 -0
@@ -20,7 +20,6 @@ import {
20
20
  getCachedReadOnlyStat,
21
21
  statCacheSet,
22
22
  statPathsForMtime,
23
- lstatPathsForMtime,
24
23
  registerCacheInvalidationListener,
25
24
  } from './cache-layers.mjs';
26
25
  import {
@@ -28,8 +27,11 @@ import {
28
27
  NOISE_DIR_NAMES,
29
28
  walkDir,
30
29
  } from './glob-walk.mjs';
31
- import { formatMtime, formatListSize } from './list-formatting.mjs';
32
- import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
30
+ import {
31
+ capLineOrientedToolOutput,
32
+ LOCATOR_OUTPUT_MAX_BYTES,
33
+ TOOL_OUTPUT_MAX_BYTES,
34
+ } from './tool-output-limit.mjs';
33
35
  import { runRg } from './rg-runner.mjs';
34
36
  import { hasSpareCapacity as childSpawnHasSpareCapacity } from '../../../../shared/child-spawn-gate.mjs';
35
37
  import { fuzzyRank } from './fuzzy-match.mjs';
@@ -52,6 +54,36 @@ function _findDefaultHeadLimit(fallback) {
52
54
  return parsed > 0 ? parsed : fallback;
53
55
  }
54
56
 
57
+ function _findOutputBudgetBytes(options = {}) {
58
+ const requested = Number(options?.__findOutputBudgetBytes);
59
+ const explicit = Number(options?.toolOutputMaxBytes);
60
+ return Math.max(1, Math.min(
61
+ LOCATOR_OUTPUT_MAX_BYTES,
62
+ Number.isFinite(requested) && requested > 0 ? Math.trunc(requested) : LOCATOR_OUTPUT_MAX_BYTES,
63
+ Number.isFinite(explicit) && explicit > 0 ? Math.trunc(explicit) : LOCATOR_OUTPUT_MAX_BYTES,
64
+ ));
65
+ }
66
+
67
+ function _splitFindBudget(total, count, index) {
68
+ if (!(count > 0)) return 0;
69
+ const base = Math.floor(total / count);
70
+ return base + (index < (total % count) ? 1 : 0);
71
+ }
72
+
73
+ // Entry paths render relative to the listed root — the caller supplied the
74
+ // base via `path`, so repeating the absolute prefix on every row is pure
75
+ // duplication (~2KB per 80 rows). Falls back to the absolute form when the
76
+ // entry escapes the root (drive change / `..`), where a relative form would
77
+ // be ambiguous. Cache-safe: keys already include the absolute root, and the
78
+ // rendered rows no longer depend on the session workDir.
79
+ function displayRelPath(entPath, rootPath) {
80
+ const rel = relative(rootPath, entPath);
81
+ if (!rel || rel === '.' || rel.startsWith('..') || /^[A-Za-z]:/.test(rel)) {
82
+ return normalizeOutputPath(entPath);
83
+ }
84
+ return normalizeOutputPath(rel);
85
+ }
86
+
55
87
  export async function executeListTool(args, workDir, options = {}) {
56
88
  args.path = coerceReadFamilyPathArg(args.path, workDir);
57
89
  if (Array.isArray(args.path)) {
@@ -209,20 +241,10 @@ export async function executeListTool(args, workDir, options = {}) {
209
241
 
210
242
  const windowed = offset > 0 ? rows.slice(offset) : rows;
211
243
  const sliced = headLimit > 0 ? windowed.slice(0, headLimit) : windowed;
212
- if (!needsGlobalStat && sliced.length > 0) {
213
- // Use lstat so a symlink reports its own size/mtime instead of
214
- // the target's. The walker already typed symlinks from Dirent;
215
- // following the link here would lie about the listed entry.
216
- const stats = await lstatPathsForMtime(sliced.map((row) => row.fullPath), workDir, 64, { deadlineMs: 5000 });
217
- for (let i = 0; i < sliced.length; i++) {
218
- const item = stats[i];
219
- if (!item?.stat) continue;
220
- sliced[i].size = item.size;
221
- sliced[i].mtimeMs = item.mtimeMs;
222
- }
223
- }
224
- const lines = sliced.map(r =>
225
- `${normalizeOutputPath(r.path)}\t${r.type}\t${formatListSize(r.type, r.size)}\t${formatMtime(r.mtimeMs)}`);
244
+ // Paths and entry types are the list contract. Size/mtime columns were
245
+ // neither exposed in the model schema nor used by routing, yet consumed
246
+ // roughly 28–40% of live list output and forced a stat per visible entry.
247
+ const lines = sliced.map(r => `${displayRelPath(r.path, fullPath)}\t${r.type}`);
226
248
  if (windowed.length > sliced.length) lines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${rows.length}; pass offset:${offset + sliced.length} to continue]`);
227
249
  if (truncatedByCap) lines.push(`... walk truncated at ${LIST_ABSOLUTE_CAP} rows or ${LIST_WALK_TIMEOUT_MS}ms timeout; narrow the path or lower depth for a complete listing`);
228
250
  let emptyMsg = '(empty directory)';
@@ -563,24 +585,60 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
563
585
  const capped = list.length > 5;
564
586
  const targets = capped ? list.slice(0, 5) : list;
565
587
  if (targets.length > 1) {
588
+ // head_limit is one call-level result budget, not a multiplier per
589
+ // query. Split it deterministically in caller order; if it cannot
590
+ // fund one result per query, return the remaining queries exactly.
591
+ const totalHeadLimit = normalizeListHeadLimit(args.head_limit, _findDefaultHeadLimit(25));
592
+ const activeCount = totalHeadLimit > 0
593
+ ? Math.min(targets.length, totalHeadLimit)
594
+ : targets.length;
595
+ const activeTargets = targets.slice(0, activeCount);
596
+ const omittedByHeadLimit = targets.slice(activeCount);
597
+ const notes = [];
598
+ if (omittedByHeadLimit.length) {
599
+ notes.push(
600
+ `... [head_limit ${totalHeadLimit} exhausted across query[]; `
601
+ + `retry query=${JSON.stringify(omittedByHeadLimit)}]`,
602
+ );
603
+ }
604
+ if (capped) notes.push(`... [capped at 5 of ${list.length} queries]`);
605
+ const sectionHeaders = activeTargets.map((query) => `# find ${query}\n`);
606
+ const separatorBytes = Math.max(0, activeTargets.length + notes.length - 1) * 2;
607
+ const fixedBytes = sectionHeaders.reduce(
608
+ (sum, header) => sum + Buffer.byteLength(header, 'utf8'),
609
+ separatorBytes + notes.reduce((sum, note) => sum + Buffer.byteLength(note, 'utf8'), 0),
610
+ );
611
+ const bodyBudget = Math.max(activeTargets.length, _findOutputBudgetBytes(options) - fixedBytes);
566
612
  // Full parallelism is intentional. All sections share one common
567
613
  // broad enumeration and one unioned ignored/truncated targeted pass,
568
- // but ranking and output remain isolated per query.
614
+ // while each query receives a fair share of the call-level count and
615
+ // byte budgets before any aggregate result is assembled.
569
616
  const batchContext = {
570
- queries: targets,
617
+ queries: activeTargets,
571
618
  targetedRuns: new Map(),
572
619
  itemsByFiles: new WeakMap(),
573
620
  };
574
- const nestedOptions = { ...options, __findBatchContext: batchContext };
575
- const bodies = await Promise.all(targets.map(async (query) => {
621
+ const bodies = await Promise.all(activeTargets.map(async (query, index) => {
622
+ const queryHeadLimit = totalHeadLimit > 0
623
+ ? _splitFindBudget(totalHeadLimit, activeTargets.length, index)
624
+ : 0;
625
+ const nestedOptions = {
626
+ ...options,
627
+ __findBatchContext: batchContext,
628
+ __findOutputBudgetBytes: _splitFindBudget(bodyBudget, activeTargets.length, index),
629
+ };
576
630
  try {
577
- return await executeFuzzyFindTool({ ...args, query }, workDir, nestedOptions);
631
+ return await executeFuzzyFindTool(
632
+ { ...args, query, head_limit: queryHeadLimit },
633
+ workDir,
634
+ nestedOptions,
635
+ );
578
636
  } catch (err) {
579
637
  return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
580
638
  }
581
639
  }));
582
- const sections = targets.map((q, i) => `# find ${q}\n${bodies[i]}`);
583
- if (capped) sections.push(`... [capped at 5 of ${list.length} queries]`);
640
+ const sections = activeTargets.map((q, i) => `${sectionHeaders[i]}${bodies[i]}`);
641
+ sections.push(...notes);
584
642
  return sections.join('\n\n');
585
643
  }
586
644
  args.query = targets[0];
@@ -616,7 +674,12 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
616
674
  includeNoise,
617
675
  });
618
676
  const cached = cacheGet(cacheKey);
619
- if (cached !== null) return cached;
677
+ const capFindResult = (value) => capLineOrientedToolOutput(
678
+ value,
679
+ _findOutputBudgetBytes(options),
680
+ () => `... [find result budget reached for query=${JSON.stringify(query)}; narrow path/head_limit]`,
681
+ );
682
+ if (cached !== null) return capFindResult(cached);
620
683
  // Common discovery respects .gitignore even outside a Git repository.
621
684
  // include_noise deliberately retains the old hardened --no-ignore behavior.
622
685
  // Noise dirs stay excluded via DEFAULT_IGNORE_GLOBS below.
@@ -751,7 +814,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
751
814
  if (typeof options?.onProgress === 'function') {
752
815
  try { options.onProgress(`${ranked.length} candidates`); } catch { /* best-effort */ }
753
816
  }
754
- return result;
817
+ return capFindResult(result);
755
818
  }
756
819
 
757
820
  export async function executeFindFilesTool(args, workDir, options = {}) {
@@ -1014,7 +1077,7 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
1014
1077
  const windowed = offset > 0 ? matches.slice(offset) : matches;
1015
1078
  const sliced = headLimit > 0 ? windowed.slice(0, headLimit) : windowed;
1016
1079
  const lines = sliced.map(m =>
1017
- `${normalizeOutputPath(m.path)}\t${formatListSize('file', m.size)}\t${formatMtime(m.mtimeMs)}`);
1080
+ `${displayRelPath(m.path, fullPath)}\t${formatListSize('file', m.size)}\t${formatMtime(m.mtimeMs)}`);
1018
1081
  if (windowed.length > sliced.length) lines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${matches.length}; pass offset:${offset + sliced.length} to continue]`);
1019
1082
  if (rgStdoutTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; results incomplete');
1020
1083
  if (rgStdoutPartial) lines.push('... [warning] rg exit 2 (partial results); listing may be incomplete');
@@ -103,6 +103,11 @@ function firstPresentArg(args, names) {
103
103
  return undefined;
104
104
  }
105
105
 
106
+ // Shared semantic default for grep calls that omit output_mode/context.
107
+ // Scoped-cache canonicalization imports this so omitted context and an
108
+ // explicit context:25 resolve to the same cache key.
109
+ export const GREP_AUTO_CONTEXT_LINES = 25;
110
+
106
111
  export function normalizeGrepArgs(args) {
107
112
  if (!args || typeof args !== 'object') return args;
108
113
  if (args.pattern === undefined || args.pattern === null || args.pattern === '') {
@@ -119,7 +124,7 @@ export function normalizeGrepArgs(args) {
119
124
  }
120
125
  if ((args.output_mode === undefined || args.output_mode === null || args.output_mode === '') && typeof args.mode === 'string') {
121
126
  const mode = args.mode.trim();
122
- if (['files_with_matches', 'content', 'count'].includes(mode)) args.output_mode = mode;
127
+ if (['files_with_matches', 'content', 'content_with_context', 'count'].includes(mode)) args.output_mode = mode;
123
128
  }
124
129
  return args;
125
130
  }
@@ -54,7 +54,7 @@ export function sliceReadBodyByLines(body, origOffset, origLimit) {
54
54
  const _remaining = haveTotal ? totalNum - emittedLast : null;
55
55
  const continuationPart = moreToRead && Number.isFinite(emittedLast)
56
56
  ? (_remaining !== null && _remaining > 0 && _remaining <= 600
57
- ? `; ${_remaining} lines left — take the rest in ONE window: offset:${emittedLast}, limit:${_remaining}`
57
+ ? `; ${_remaining} left — ONE window: offset:${emittedLast} limit:${_remaining}`
58
58
  : `; pass offset:${emittedLast} to continue`)
59
59
  : '';
60
60
  const newFooter = `[lines ${firstLine}-${emittedLast}${totalPart}${continuationPart}]`;
@@ -99,6 +99,9 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
99
99
  _rawContentCacheSet,
100
100
  _recordReadSnapshot,
101
101
  } = helpers;
102
+ const _readMaxOutputBytes = Number(options?.readOutputBudgetBytes) > 0
103
+ ? Math.min(READ_MAX_OUTPUT_BYTES, Math.trunc(Number(options.readOutputBudgetBytes)))
104
+ : READ_MAX_OUTPUT_BYTES;
102
105
  // Normalize path (strip whitespace, expand ~, posix→windows) up front so
103
106
  // LLM-injected stray spaces don't trigger an ENOENT retry that pollutes
104
107
  // the conversation history and breaks the cache prefix on later turns.
@@ -454,7 +457,10 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
454
457
  return withSymbolReadNote(text, args);
455
458
  }
456
459
  try {
457
- const _streamRes = await streamReadRange(fullPath, offset, limit, st, { displayPath: filePath });
460
+ const _streamRes = await streamReadRange(fullPath, offset, limit, st, {
461
+ displayPath: filePath,
462
+ maxOutputBytes: _readMaxOutputBytes,
463
+ });
458
464
  const out = _streamRes.text;
459
465
  // W1 H: snapshot only emitted line bounds, not the
460
466
  // requested window — byte-cap truncation can stop short.
@@ -547,9 +553,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
547
553
  // Fall through to the regular read path; it still enforces output caps.
548
554
  }
549
555
  }
550
- if (!_isUtf16 && hasRangeArgs && !wantFull && st.size > READ_STREAM_RANGE_MIN_BYTES) {
556
+ if (!_isUtf16 && hasRangeArgs && !wantFull
557
+ && (options?.forceReadRangeStream === true || st.size > READ_STREAM_RANGE_MIN_BYTES)) {
551
558
  try {
552
- const _streamRes = await streamReadRange(fullPath, offset, limit, st, { displayPath: filePath });
559
+ const _streamRes = await streamReadRange(fullPath, offset, limit, st, {
560
+ displayPath: filePath,
561
+ maxOutputBytes: _readMaxOutputBytes,
562
+ });
553
563
  const out = _streamRes.text;
554
564
  const _emittedRanges = (_streamRes.firstEmitted && _streamRes.lastEmitted)
555
565
  ? [{ startLine: _streamRes.firstEmitted, endLine: _streamRes.lastEmitted }]
@@ -649,19 +659,19 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
649
659
  _smartTruncated = true;
650
660
  _smartVisibleRanges = Array.isArray(smart.ranges) ? smart.ranges : null;
651
661
  _renderedLineCount = 0;
652
- } else if (Buffer.byteLength(rendered, 'utf8') > READ_MAX_OUTPUT_BYTES) {
662
+ } else if (Buffer.byteLength(rendered, 'utf8') > _readMaxOutputBytes) {
653
663
  let lo = 0;
654
664
  let hi = rendered.length;
655
665
  while (lo < hi) {
656
666
  const mid = Math.ceil((lo + hi) / 2);
657
- if (Buffer.byteLength(rendered.slice(0, mid), 'utf8') <= READ_MAX_OUTPUT_BYTES) lo = mid;
667
+ if (Buffer.byteLength(rendered.slice(0, mid), 'utf8') <= _readMaxOutputBytes) lo = mid;
658
668
  else hi = mid - 1;
659
669
  }
660
670
  const slice = rendered.slice(0, lo);
661
671
  const completeRenderedLines = Math.max(0, slice.split('\n').length - 1);
662
672
  _renderedLineCount = completeRenderedLines;
663
673
  _byteCapTruncated = true;
664
- out = slice + `\n\n... [output truncated at ${Math.round(READ_MAX_OUTPUT_BYTES/1024)} KB] ...`;
674
+ out = slice + `\n\n... [output truncated at ${Math.round(_readMaxOutputBytes/1024)} KB] ...`;
665
675
  } else {
666
676
  out = rendered;
667
677
  }
@@ -671,10 +681,10 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
671
681
  } else if (_byteCapTruncated) {
672
682
  const emittedStart = offset + 1;
673
683
  const emittedEnd = offset + _renderedLineCount;
674
- const capKb = Math.round(READ_MAX_OUTPUT_BYTES / 1024);
684
+ const capKb = Math.round(_readMaxOutputBytes / 1024);
675
685
  const footer = `[lines ${emittedStart}-${emittedEnd} of ${lineCount}; output truncated at ${capKb} KB${emittedEnd < lineCount ? `; pass offset:${emittedEnd} to continue` : ''}]`;
676
686
  out += `${out ? '\n' : ''}${footer}`;
677
- } else if (Buffer.byteLength(rendered, 'utf8') <= READ_MAX_OUTPUT_BYTES) {
687
+ } else if (Buffer.byteLength(rendered, 'utf8') <= _readMaxOutputBytes) {
678
688
  const emittedStart = offset + 1;
679
689
  const emittedEnd = offset + sliced.length;
680
690
  // Anti-fragmentation: when the remainder is modest, hand the
@@ -683,7 +693,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
683
693
  const _remaining = lineCount - emittedEnd;
684
694
  const _cont = emittedEnd < lineCount
685
695
  ? (_remaining <= 600
686
- ? `; ${_remaining} lines left — take the rest in ONE window: offset:${emittedEnd}, limit:${_remaining}`
696
+ ? `; ${_remaining} left — ONE window: offset:${emittedEnd} limit:${_remaining}`
687
697
  : `; pass offset:${emittedEnd} to continue`)
688
698
  : '';
689
699
  const footer = `[lines ${emittedStart}-${emittedEnd} of ${lineCount}${_cont}]`;
@@ -62,6 +62,10 @@ export async function streamReadRange(fullPath, offset, limit, stHint = null, ho
62
62
  let pendingBytes = 0;
63
63
  let lineCollectCapped = false;
64
64
  let prefixHash = rangeIndex?.prefixHash || '';
65
+ const maxOutputBytes = Number(hooks.maxOutputBytes) > 0
66
+ ? Math.min(READ_MAX_OUTPUT_BYTES, Math.trunc(Number(hooks.maxOutputBytes)))
67
+ : READ_MAX_OUTPUT_BYTES;
68
+ const bodyOutputBytes = Math.max(1, maxOutputBytes - Math.min(384, Math.floor(maxOutputBytes / 3)));
65
69
  const deadline = Date.now() + READ_STREAM_TIMEOUT_MS;
66
70
  let bytesScanned = anchor.byteOffset;
67
71
 
@@ -85,7 +89,7 @@ export async function streamReadRange(fullPath, offset, limit, stHint = null, ho
85
89
  // Truncation drops the entire rendered line (codepoint-safe by
86
90
  // construction), so no mid-codepoint cut can occur here.
87
91
  collectedBytes += Buffer.byteLength(rendered, 'utf8') + 1;
88
- if (collectedBytes > READ_MAX_OUTPUT_BYTES) {
92
+ if (collectedBytes > bodyOutputBytes) {
89
93
  truncated = true;
90
94
  return false;
91
95
  }
@@ -207,7 +211,8 @@ export async function streamReadRange(fullPath, offset, limit, stHint = null, ho
207
211
 
208
212
  let out = collected.join('\n');
209
213
  if (truncated) {
210
- out += `\n\n... [output truncated at ${Math.round(READ_MAX_OUTPUT_BYTES/1024)} KB] ...`;
214
+ const nextOffset = lastEmitted || offset;
215
+ out += `\n\n... [output truncated at ${Math.max(1, Math.round(maxOutputBytes/1024))} KB; pass offset:${nextOffset} to continue] ...`;
211
216
  } else if (stoppedAtLimit) {
212
217
  out += `${out ? '\n' : ''}... [range limit reached; next offset: ${offset + collected.length}]`;
213
218
  } else if (!out && offset >= lineIdx) {
@@ -113,6 +113,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
113
113
  _rangeHashesFromRenderedReadText,
114
114
  _readEntryLineWindow,
115
115
  _recordReadSnapshot,
116
+ READ_MAX_OUTPUT_BYTES,
116
117
  } = helpers;
117
118
  // CC `file_path` alias — official SDK schema uses `file_path`;
118
119
  // mixdog has historically used `path`. Honor `file_path` so a
@@ -310,6 +311,21 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
310
311
  if (a.offset !== b.offset) return a.offset - b.offset;
311
312
  return a.index - b.index;
312
313
  });
314
+ // A path[] Read shares one byte ceiling. Reserve room for headers and
315
+ // split the remaining body budget across distinct disk windows.
316
+ const _readCallBudget = Number(options?.readOutputBudgetBytes) > 0
317
+ ? Math.min(READ_MAX_OUTPUT_BYTES, Math.trunc(Number(options.readOutputBudgetBytes)))
318
+ : READ_MAX_OUTPUT_BYTES;
319
+ const _headerReserve = Math.min(
320
+ Math.max(2_048, entries.reduce((sum, entry) => (
321
+ sum + Buffer.byteLength(String(normalizeOutputPath(entry?.path || '(missing-path)')), 'utf8') + 128
322
+ ), 0)),
323
+ Math.floor(_readCallBudget / 2),
324
+ );
325
+ const _perTaskOutputBudget = Math.max(
326
+ 256,
327
+ Math.floor((_readCallBudget - _headerReserve) / Math.max(1, tasks.length)),
328
+ );
313
329
  const results = new Array(entries.length);
314
330
  const readChains = new Map();
315
331
  await Promise.all(tasks.map(({ entry, index }) => {
@@ -319,9 +335,15 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
319
335
  }
320
336
  const run = async () => {
321
337
  const _diskWin = readEntryCoalescedDiskWindow(entry);
322
- const readEntry = _diskWin
338
+ let readEntry = _diskWin
323
339
  ? { ...entry, offset: _diskWin.offset, limit: _diskWin.limit }
324
340
  : entry;
341
+ if ((!readEntry.mode || readEntry.mode === 'full')
342
+ && readEntry.offset == null
343
+ && readEntry.limit == null
344
+ && readEntry.full !== true) {
345
+ readEntry = { ...readEntry, offset: 0, limit: 2000 };
346
+ }
325
347
  // Full image children retain their rich blocks; the aggregate
326
348
  // assembler below flattens them without stringification.
327
349
  // Other media (PDF/notebook) remains text-only in a batch so
@@ -332,6 +354,9 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
332
354
  suppressReadUnchangedStub: true,
333
355
  mediaTextOnly: !richImage,
334
356
  _skipReachPreflight: true,
357
+ forceReadRangeStream: true,
358
+ readOutputBudgetBytes: _perTaskOutputBudget,
359
+ toolOutputMaxBytes: _perTaskOutputBudget,
335
360
  });
336
361
  results[index] = { path: entry.path, mode: entry.mode || 'full', n: entry.n, body };
337
362
  };
@@ -470,11 +495,13 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
470
495
  const r = orderedResults[i];
471
496
  const path = normalizeOutputPath(r.path);
472
497
  const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
498
+ // Default full mode carries no information — tag only non-default modes.
499
+ const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
473
500
  const status = bodyFailed(r.body) ? 'error' : 'ok';
474
501
  const textBody = bodyTextFor(r.body);
475
502
  const match = /\[TRUNCATED (?:—|-) file is (\d+) lines \/ (\d+) KB\./.exec(textBody);
476
503
  const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
477
- const entryHeader = `${path} [${mode}] [${status}]${suffix}`;
504
+ const entryHeader = `${path}${modeTag} [${status}]${suffix}`;
478
505
  const richParts = richPartsFor(r.body);
479
506
  let priorIdx;
480
507
  if (richParts) {
@@ -513,16 +540,17 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
513
540
  const body = orderedResults.map((r, _i) => {
514
541
  const path = normalizeOutputPath(r.path);
515
542
  const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
543
+ const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
516
544
  const status = classifyResultKind(String(r.body || '')) === 'error' ? 'error' : 'ok';
517
545
  const dupKey = JSON.stringify([path, mode, r.body || '']);
518
546
  const priorIdx = _seenEntryBody.get(dupKey);
519
547
  if (priorIdx !== undefined) {
520
- return `${path} [${mode}] [${status}] [= entry #${priorIdx + 1}, identical result omitted]`;
548
+ return `${path}${modeTag} [${status}] [= entry #${priorIdx + 1}, identical result omitted]`;
521
549
  }
522
550
  _seenEntryBody.set(dupKey, _i);
523
551
  const match = /\[TRUNCATED (?:—|-) file is (\d+) lines \/ (\d+) KB\./.exec(r.body || '');
524
552
  const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
525
- return `${path} [${mode}] [${status}]${suffix}\n${r.body}`;
553
+ return `${path}${modeTag} [${status}]${suffix}\n${r.body}`;
526
554
  }).join('\n\n');
527
555
  return `${header}\n\n${body}${args._batchCapNote ? `\n\n${args._batchCapNote}` : ''}`;
528
556
  }
@@ -31,7 +31,9 @@ export function buildGrepCacheKey(parts) {
31
31
  onlyMatching,
32
32
  pcre2,
33
33
  withFilename,
34
+ contextCharBudget = 0,
34
35
  patternCapTotal = 0,
36
+ candidatesKey = '',
35
37
  } = parts;
36
38
  return [
37
39
  'grep',
@@ -51,10 +53,15 @@ export function buildGrepCacheKey(parts) {
51
53
  Array.isArray(fileType) ? fileType.join('\x01') : (fileType || ''),
52
54
  pcre2 ? 'p1' : 'p0',
53
55
  withFilename ? 'H1' : 'H0',
56
+ 'cb' + String(contextCharBudget || 0),
54
57
  // Cap total keeps a capped request (first-N of M patterns, carrying the
55
58
  // "[capped at N of M]" notice) from colliding with an exact N-pattern
56
59
  // request or with a differently-capped one (of 15 vs of 20).
57
60
  'pc' + String(patternCapTotal || 0),
61
+ // Candidate-file scoping (fan-out prefilter) changes which files rg
62
+ // visits and their order; scoped results get their own cache slot so
63
+ // they never collide with an unscoped run of the same pattern.
64
+ 'cd' + String(candidatesKey || ''),
58
65
  ].join('|');
59
66
  }
60
67
 
@@ -75,6 +82,7 @@ export function buildGrepRgArgs(parts) {
75
82
  fixedStrings = false,
76
83
  pcre2 = false,
77
84
  withFilename = false,
85
+ candidateFiles = null,
78
86
  } = parts;
79
87
  // `--hidden`: search dotfiles/dot-dirs (.github, .mixdog) that
80
88
  // rg skips by default. The DEFAULT_IGNORE_GLOBS below still exclude .git and
@@ -127,7 +135,14 @@ export function buildGrepRgArgs(parts) {
127
135
  // `--type` is treated as a positional path, not parsed as an rg
128
136
  // option. Patterns already use `-e`, so the separator only needs to
129
137
  // guard the trailing path operand.
130
- rgArgs.push('--', searchPath);
138
+ if (Array.isArray(candidateFiles) && candidateFiles.length > 0) {
139
+ // Fan-out prefilter scoping: search ONLY the candidate files (paths
140
+ // relative to the spawn cwd) instead of walking searchPath — explicit
141
+ // file operands skip the directory walk entirely.
142
+ rgArgs.push('--', ...candidateFiles);
143
+ } else {
144
+ rgArgs.push('--', searchPath);
145
+ }
131
146
  return rgArgs;
132
147
  }
133
148