mixdog 0.9.93 → 0.9.95

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 (186) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/agent/30-explorer.md +22 -16
  11. package/src/rules/lead/01-general.md +1 -2
  12. package/src/rules/lead/lead-brief.md +4 -5
  13. package/src/rules/lead/lead-tool.md +3 -2
  14. package/src/rules/shared/01-tool.md +28 -29
  15. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +23 -7
  17. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  21. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  22. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  26. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  28. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  29. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  30. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  32. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  33. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  34. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  35. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  38. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  39. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  40. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  41. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  42. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  43. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  44. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  47. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  50. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  52. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +103 -92
  55. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  57. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  58. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +25 -3
  59. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  66. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +20 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  75. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  77. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +546 -23
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +8 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  84. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  86. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +7 -3
  87. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +104 -14
  88. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  89. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  90. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  91. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  92. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +5 -5
  94. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  96. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +151 -18
  97. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  99. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  100. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  101. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  102. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  103. package/src/runtime/channels/backends/discord.mjs +5 -14
  104. package/src/runtime/channels/backends/telegram.mjs +0 -5
  105. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  106. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  107. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  108. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  109. package/src/runtime/media/renditions.mjs +21 -2
  110. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  111. package/src/runtime/memory/tool-defs.mjs +6 -6
  112. package/src/runtime/shared/atomic-file.mjs +53 -0
  113. package/src/runtime/shared/background-tasks.mjs +16 -6
  114. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  115. package/src/runtime/shared/resource-admission.mjs +40 -1
  116. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  117. package/src/runtime/shared/tool-card-model.mjs +6 -2
  118. package/src/runtime/shared/tool-status.mjs +10 -1
  119. package/src/runtime/shared/tool-surface.mjs +5 -0
  120. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  121. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  122. package/src/session-runtime/context-status.mjs +9 -3
  123. package/src/session-runtime/lifecycle-api.mjs +35 -5
  124. package/src/session-runtime/mcp-glue.mjs +11 -6
  125. package/src/session-runtime/provider-models.mjs +94 -43
  126. package/src/session-runtime/provider-usage.mjs +26 -2
  127. package/src/session-runtime/runtime-core.mjs +46 -8
  128. package/src/session-runtime/runtime-tunables.mjs +4 -0
  129. package/src/session-runtime/self-update.mjs +33 -4
  130. package/src/session-runtime/session-lifecycle.mjs +2 -0
  131. package/src/session-runtime/session-text.mjs +2 -1
  132. package/src/session-runtime/session-turn-api.mjs +106 -22
  133. package/src/session-runtime/workflow.mjs +11 -4
  134. package/src/standalone/agent-tool.mjs +4 -4
  135. package/src/standalone/backend-daemon.mjs +570 -0
  136. package/src/standalone/channel-daemon-transport.mjs +141 -1
  137. package/src/standalone/channel-worker.mjs +3 -2
  138. package/src/standalone/engine-daemon-client.mjs +894 -0
  139. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  140. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  141. package/src/standalone/engine-daemon-service.mjs +864 -0
  142. package/src/standalone/engine-daemon-transport.mjs +603 -0
  143. package/src/standalone/explore-tool.mjs +1 -1
  144. package/src/tui/App.jsx +62 -47
  145. package/src/tui/app/app-format.mjs +4 -2
  146. package/src/tui/app/app-view.jsx +5 -0
  147. package/src/tui/app/channel-pickers.mjs +7 -6
  148. package/src/tui/app/core-memory-picker.mjs +4 -4
  149. package/src/tui/app/extension-pickers.mjs +20 -18
  150. package/src/tui/app/maintenance-pickers.mjs +27 -27
  151. package/src/tui/app/onboarding-steps.mjs +23 -18
  152. package/src/tui/app/prompt-submit.mjs +13 -2
  153. package/src/tui/app/route-pickers.mjs +13 -8
  154. package/src/tui/app/settings-picker.mjs +55 -58
  155. package/src/tui/app/slash-dispatch.mjs +32 -28
  156. package/src/tui/app/transcript-window.mjs +19 -0
  157. package/src/tui/app/usage-context-panels.mjs +22 -7
  158. package/src/tui/app/use-mouse-input.mjs +25 -3
  159. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  160. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  161. package/src/tui/app/use-transcript-window.mjs +22 -1
  162. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  163. package/src/tui/components/PromptInput.jsx +10 -0
  164. package/src/tui/components/Spinner.jsx +89 -86
  165. package/src/tui/components/TextEntryPanel.jsx +14 -0
  166. package/src/tui/components/ToolExecution.jsx +2 -2
  167. package/src/tui/dist/index.mjs +1537 -9718
  168. package/src/tui/engine/agent-job-feed.mjs +2 -2
  169. package/src/tui/engine/live-share.mjs +97 -4
  170. package/src/tui/engine/session-api-ext.mjs +23 -1
  171. package/src/tui/engine/session-api.mjs +88 -41
  172. package/src/tui/engine/session-flow.mjs +43 -4
  173. package/src/tui/engine/tool-card-results.mjs +6 -0
  174. package/src/tui/engine/turn.mjs +84 -4
  175. package/src/tui/engine-local-session.mjs +1108 -0
  176. package/src/tui/engine.mjs +16 -1057
  177. package/src/tui/index.jsx +47 -2
  178. package/src/tui/markdown/stream-fence.mjs +1 -1
  179. package/src/tui/spinner-meta.mjs +80 -0
  180. package/src/tui/spinner-verbs.mjs +35 -0
  181. package/src/ui/statusline-segments.mjs +43 -10
  182. package/src/ui/statusline.mjs +10 -1
  183. package/scripts/tmp-cdp-errors.mjs +0 -41
  184. package/scripts/tmp-cdp-inspect.mjs +0 -41
  185. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  186. package/src/standalone/channel-daemon.mjs +0 -226
@@ -5,9 +5,11 @@
5
5
  import { normalizeOutputPath } from '../path-utils.mjs';
6
6
  import {
7
7
  groupGrepContentByFile,
8
+ parseGrepContextHeader,
8
9
  splitGrepLineNumberOnlyPrefix,
9
10
  splitGrepLinePrefix,
10
11
  } from '../grep-formatting.mjs';
12
+ import { relativePathPrefix } from '../search-path-diagnostics.mjs';
11
13
  import { relativeGrepLine } from './search-input-helpers.mjs';
12
14
 
13
15
  export function grepMissingPatternMessage() {
@@ -22,30 +24,84 @@ export function globMissingPatternMessage() {
22
24
  // In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
23
25
  // and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
24
26
  // head+tail slice with a middle marker instead of dropping the tail.
25
- function grepBlockMatchAnchor(line, filenameOmitted) {
27
+ function grepContextSourceLine(line, filenameOmitted, fallbackPath = '') {
26
28
  if (filenameOmitted) {
27
29
  const p = splitGrepLineNumberOnlyPrefix(line);
28
- return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
30
+ return p ? {
31
+ path: fallbackPath,
32
+ lineNo: p.lineNo,
33
+ delimiter: p.delimiter,
34
+ content: String(line).slice(p.markerEnd),
35
+ } : null;
29
36
  }
30
37
  const s = splitGrepLinePrefix(line);
31
- return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
38
+ return s ? {
39
+ path: s.path,
40
+ lineNo: s.lineNo,
41
+ delimiter: s.delimiter,
42
+ content: String(line).slice(s.markerEnd),
43
+ } : null;
32
44
  }
33
45
 
34
- function parseGrepContextBlocks(lines, filenameOmitted) {
46
+ function grepBlockMatchAnchor(line, filenameOmitted, fallbackPath) {
47
+ const parsed = grepContextSourceLine(line, filenameOmitted, fallbackPath);
48
+ return parsed && parsed.delimiter === ':' ? `${parsed.path}\0${parsed.lineNo}` : '';
49
+ }
50
+
51
+ function parseRenderableGrepContextBlock(block, filenameOmitted, fallbackPath) {
52
+ const parsed = block.lines.map((line) => grepContextSourceLine(line, filenameOmitted, fallbackPath));
53
+ const contiguous = parsed.length > 0
54
+ && parsed.every((entry, index) => entry
55
+ && entry.path
56
+ && entry.path === parsed[0].path
57
+ && (index === 0 || entry.lineNo === parsed[index - 1].lineNo + 1));
58
+ const match = contiguous ? parsed.find((entry) => entry.delimiter === ':') : null;
59
+ if (!match) return { raw: block.lines.join('\n') };
60
+ return {
61
+ path: match.path,
62
+ matchLine: match.lineNo,
63
+ startLine: parsed[0].lineNo,
64
+ endLine: parsed.at(-1).lineNo,
65
+ contents: parsed.map((entry) => entry.content),
66
+ };
67
+ }
68
+
69
+ function renderGrepContextBlocks(blocks, filenameOmitted, fallbackPath) {
70
+ const merged = [];
71
+ for (const block of blocks) {
72
+ const current = parseRenderableGrepContextBlock(block, filenameOmitted, fallbackPath);
73
+ const previous = merged.at(-1);
74
+ if (previous?.path
75
+ && current.path === previous.path
76
+ && current.startLine <= previous.endLine + 1) {
77
+ const overlap = Math.max(0, previous.endLine - current.startLine + 1);
78
+ previous.contents.push(...current.contents.slice(overlap));
79
+ previous.endLine = Math.max(previous.endLine, current.endLine);
80
+ continue;
81
+ }
82
+ merged.push(current);
83
+ }
84
+ return merged.map((block) => {
85
+ if (!block.path) return block.raw;
86
+ return `# ${block.path}:${block.matchLine} [lines ${block.startLine}-${block.endLine}]\n${block.contents.join('\n')}`;
87
+ });
88
+ }
89
+
90
+ function parseGrepContextBlocks(lines, filenameOmitted, fallbackPath) {
35
91
  const blocks = [];
36
92
  let pending = [];
37
93
  let i = 0;
38
94
  while (i < lines.length) {
39
95
  const line = lines[i];
40
96
  if (line === '--') { pending = []; i++; continue; }
41
- const anchor = grepBlockMatchAnchor(line, filenameOmitted);
97
+ const anchor = grepBlockMatchAnchor(line, filenameOmitted, fallbackPath);
42
98
  if (anchor) {
43
99
  const blockLines = pending.concat([line]);
44
100
  pending = [];
45
101
  i++;
46
102
  while (i < lines.length) {
47
103
  const next = lines[i];
48
- if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
104
+ if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted, fallbackPath)) break;
49
105
  blockLines.push(next);
50
106
  i++;
51
107
  }
@@ -58,9 +114,12 @@ function parseGrepContextBlocks(lines, filenameOmitted) {
58
114
  return blocks;
59
115
  }
60
116
 
61
- export function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
117
+ export function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, searchPath, totalKnown = true }) {
62
118
  const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
63
- const blocks = parseGrepContextBlocks(norm, filenameOmitted);
119
+ const fallbackPath = filenameOmitted
120
+ ? relativePathPrefix(normalizeOutputPath(searchPath), workDir)
121
+ : '';
122
+ const blocks = parseGrepContextBlocks(norm, filenameOmitted, fallbackPath);
64
123
  const total = blocks.length;
65
124
  if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
66
125
  // Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
@@ -78,7 +137,7 @@ export function formatGrepContextOutput({ allLines, workDir, outputMode, filenam
78
137
  }
79
138
  const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
80
139
  const omitted = afterOffset.length - shown;
81
- const render = (arr) => arr.map((b) => b.lines.join('\n'));
140
+ const render = (arr) => renderGrepContextBlocks(arr, filenameOmitted, fallbackPath);
82
141
  let segments;
83
142
  let nextOffset = offset + shown;
84
143
  if (omitted > 0 && shown > 0) {
@@ -99,16 +158,35 @@ export function formatGrepContextOutput({ allLines, workDir, outputMode, filenam
99
158
  const notice = (omitted > 0 || !totalKnown)
100
159
  ? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
101
160
  : '';
102
- return { text: segments.join('\n--\n') + notice, total, shown, omitted };
161
+ return {
162
+ text: `${segments.join('\n')}${notice}`,
163
+ total,
164
+ shown,
165
+ omitted,
166
+ };
103
167
  }
104
168
 
105
- // Part 1: drop path:line match lines already emitted by an earlier pattern in
106
- // a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
169
+ // Part 1: drop a numbered match line or patch-ready context block already
170
+ // emitted by an earlier pattern in a pattern[] fan-out.
107
171
  export function dedupeFanoutMatchLines(body, seen) {
108
172
  const text = String(body);
109
173
  if (/^Error:/.test(text)) return text;
110
174
  const out = [];
111
- for (const line of text.split('\n')) {
175
+ const lines = text.split('\n');
176
+ for (let i = 0; i < lines.length; i++) {
177
+ const line = lines[i];
178
+ const header = parseGrepContextHeader(line);
179
+ if (header) {
180
+ const key = `${header.path}\0${header.matchLine}`;
181
+ const duplicate = seen.has(key);
182
+ seen.add(key);
183
+ if (!duplicate) out.push(line);
184
+ for (let sourceIndex = 0; sourceIndex < header.sourceLineCount && i + 1 < lines.length; sourceIndex++) {
185
+ i++;
186
+ if (!duplicate) out.push(lines[i]);
187
+ }
188
+ continue;
189
+ }
112
190
  const s = splitGrepLinePrefix(line);
113
191
  if (s && s.delimiter === ':') {
114
192
  const key = `${s.path}\0${s.lineNo}`;
@@ -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
  }
@@ -284,6 +289,20 @@ export function coerceReadFamilyPathArg(path, workDir = null) {
284
289
  } catch { /* not a literal bracket path — allow JSON coercion */ }
285
290
  }
286
291
  }
292
+ // Absorb: a truncated JSON path array ('["C:\\a\\b.mjs' — a streamed
293
+ // argument cut mid-emit). Repaired only when exactly ONE path survives,
294
+ // so a real batch can never be silently narrowed to its first entry.
295
+ if (typeof path === 'string') {
296
+ const trimmed = path.trim();
297
+ if (trimmed.startsWith('["') && !trimmed.endsWith(']')) {
298
+ try {
299
+ const repaired = JSON.parse(`${trimmed}"]`);
300
+ if (Array.isArray(repaired) && repaired.length === 1 && typeof repaired[0] === 'string') {
301
+ return repaired[0];
302
+ }
303
+ } catch { /* not a repairable truncation — fall through */ }
304
+ }
305
+ }
287
306
  const coerced = coerceShapeFlex(path);
288
307
  if (!Array.isArray(coerced)) return coerced;
289
308
  const list = coerced
@@ -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}]`;
@@ -18,11 +18,11 @@ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
18
18
  // truncation (proceed with cap) rather than refusing. Hard in-memory cap
19
19
  // for loading a full file remains READ_MAX_SIZE_BYTES (10 MiB).
20
20
  //
21
- // READ_MAX_OUTPUT_BYTES (30 KB) — output-truncation cap. Lead-facing default tightened from 50k. Mirrors CC's
22
- // MAX_OUTPUT_SIZE intent (utils/file.ts:48) and the throw-vs-truncate
21
+ // READ_MAX_OUTPUT_BYTES (30 KB) — output-truncation cap. Lead-facing default tightened from 50k. Follows the
22
+ // common max-output-size intent and the throw-vs-truncate
23
23
  // trade-off Anthropic chose in #21841 (throw is more token-efficient).
24
- // NOTE: CC's parallel output budget is maxTokens = 25,000 tokens
25
- // (limits.ts:18), enforced post-read via a tokenizer throw. mixdog
24
+ // NOTE: the usual alternative is a ~25,000-token output budget
25
+ // enforced post-read via a tokenizer throw. mixdog
26
26
  // caps on BYTES at read time instead (no tokenizer in the hot path);
27
27
  // 30 KB ≈ well under a 25k-token budget, so the byte cap is left as-is
28
28
  // rather than converted to a token count that can't be expressed
@@ -15,8 +15,8 @@ export const API_IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024; // 5 MB
15
15
  export const IMAGE_TARGET_RAW_SIZE = (API_IMAGE_MAX_BASE64_SIZE * 3) / 4; // 3.75 MB
16
16
  const IMAGE_MAX_WIDTH = 2000;
17
17
  const IMAGE_MAX_HEIGHT = 2000;
18
- // Token budget for a single image. est tokens = base64.length * 0.125 (CC's
19
- // per-image heuristic). Default aligns to the 5MB base64 API ceiling so the
18
+ // Token budget for a single image. est tokens = base64.length * 0.125 (the
19
+ // common per-image heuristic). Default aligns to the 5MB base64 API ceiling so the
20
20
  // dimension/raw-size resize governs the common case and the token gate only
21
21
  // fires on pathologically dense images.
22
22
  const DEFAULT_IMAGE_MAX_TOKENS = Math.ceil(API_IMAGE_MAX_BASE64_SIZE * 0.125);
@@ -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}]`;
@@ -12,6 +12,7 @@ import {
12
12
  rawContentCacheSet,
13
13
  } from './cache-layers.mjs';
14
14
  import {
15
+ rememberReadSnapshot,
15
16
  readFilesForScope,
16
17
  readScopeKey,
17
18
  scheduleScopePersist,
@@ -146,7 +147,7 @@ export function recordReadSnapshot(fullPath, st, scope = null, meta = {}) {
146
147
  } else if (!batchSliced && sameFile && Number.isFinite(existing?.fileLineCount) && existing.fileLineCount >= 0) {
147
148
  next.fileLineCount = Math.trunc(existing.fileLineCount);
148
149
  }
149
- readFiles.set(fullPath, next);
150
+ rememberReadSnapshot(fullPath, next, scope, readFiles);
150
151
  scheduleScopePersist(readScopeKey(scope));
151
152
  }
152
153
 
@@ -8,8 +8,8 @@ const requireCjs = createRequire(import.meta.url);
8
8
  const DEFAULT_READ_MAX_OUTPUT_BYTES = 100 * 1024;
9
9
 
10
10
  // PDFs at or under this size are emitted as an Anthropic base64 document
11
- // block (the model reads the rendered PDF directly). Mirrors CC's
12
- // PDF_TARGET_RAW_SIZE (20MB raw → ~27MB base64, under the 32MB request cap).
11
+ // block (the model reads the rendered PDF directly): 20MB raw → ~27MB
12
+ // base64, which stays under the 32MB request cap.
13
13
  // Larger PDFs fall back to pdf-parse TEXT extraction.
14
14
  const PDF_DOCUMENT_MAX_BYTES = 20 * 1024 * 1024;
15
15
 
@@ -19,7 +19,7 @@ const PDF_DOCUMENT_MAX_BYTES = 20 * 1024 * 1024;
19
19
  const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
20
20
 
21
21
  // Per-output text ceiling inside a notebook. A single cell output larger than
22
- // this is replaced with a jq hint (port of CC's large-notebook guidance) so a
22
+ // this is replaced with a jq hint (large-notebook guidance) so a
23
23
  // runaway stdout / data dump can't blow the read budget.
24
24
  const IPYNB_OUTPUT_MAX_CHARS = 10_000;
25
25
 
@@ -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) {