@oh-my-pi/pi-coding-agent 17.3.7 → 17.3.8

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 (109) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/{CHANGELOG-1hmwwt45.md → CHANGELOG-vr9cckb4.md} +60 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/update-cli.d.ts +8 -0
  9. package/dist/types/cli-commands.d.ts +10 -2
  10. package/dist/types/config/settings-schema.d.ts +28 -0
  11. package/dist/types/config/settings.d.ts +9 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  13. package/dist/types/launch/presence.d.ts +4 -1
  14. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  15. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  16. package/dist/types/mnemopi/backend.d.ts +12 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  19. package/dist/types/modes/interactive-mode.d.ts +25 -3
  20. package/dist/types/modes/types.d.ts +10 -0
  21. package/dist/types/session/agent-session.d.ts +3 -0
  22. package/dist/types/session/prewalk.d.ts +4 -0
  23. package/dist/types/session/session-entries.d.ts +0 -1
  24. package/dist/types/session/session-manager.d.ts +12 -0
  25. package/dist/types/session/session-stats.d.ts +13 -1
  26. package/dist/types/session/skill-title-input.d.ts +13 -0
  27. package/dist/types/subprocess/worker-client.d.ts +7 -4
  28. package/dist/types/task/label.d.ts +2 -0
  29. package/dist/types/task/render.d.ts +2 -0
  30. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +6 -4
  32. package/dist/types/tiny/title-protocol.d.ts +1 -0
  33. package/dist/types/tiny/worker.d.ts +27 -0
  34. package/dist/types/tools/bash.d.ts +1 -1
  35. package/dist/types/tools/read-format.d.ts +6 -0
  36. package/dist/types/tools/read-summary.d.ts +7 -1
  37. package/dist/types/utils/block-context.d.ts +14 -0
  38. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  39. package/dist/types/utils/git.d.ts +25 -1
  40. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  41. package/package.json +13 -13
  42. package/src/advisor/advise-tool.ts +5 -3
  43. package/src/cli/auth-broker-cli.ts +36 -1
  44. package/src/cli/profile-bootstrap.ts +2 -6
  45. package/src/cli/update-cli.ts +63 -11
  46. package/src/cli-commands.ts +61 -7
  47. package/src/commands/completions.ts +2 -1
  48. package/src/commit/agentic/index.ts +15 -2
  49. package/src/commit/git/diff.ts +6 -2
  50. package/src/config/model-resolver.ts +52 -6
  51. package/src/config/models-config.ts +2 -2
  52. package/src/config/settings-schema.ts +33 -0
  53. package/src/config/settings.ts +159 -30
  54. package/src/discovery/helpers.ts +45 -2
  55. package/src/discovery/omp-plugins.ts +2 -1
  56. package/src/discovery/opencode.ts +56 -3
  57. package/src/eval/js/process-entry.ts +4 -4
  58. package/src/export/html/tool-views.generated.js +19 -19
  59. package/src/extensibility/extensions/runner.ts +3 -2
  60. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  61. package/src/launch/client.ts +9 -4
  62. package/src/launch/presence.ts +19 -4
  63. package/src/lsp/defaults.json +1 -1
  64. package/src/mcp/manager.ts +41 -20
  65. package/src/mcp/oauth-credentials.ts +38 -0
  66. package/src/mcp/oauth-flow.ts +21 -0
  67. package/src/mcp/tool-bridge.ts +32 -16
  68. package/src/mnemopi/backend.ts +35 -3
  69. package/src/modes/components/model-hub.ts +37 -4
  70. package/src/modes/components/settings-selector.ts +17 -11
  71. package/src/modes/components/tool-execution.ts +97 -29
  72. package/src/modes/components/tree-selector.ts +7 -2
  73. package/src/modes/controllers/event-controller.ts +12 -2
  74. package/src/modes/controllers/input-controller.ts +64 -27
  75. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  76. package/src/modes/interactive-mode.ts +79 -11
  77. package/src/modes/types.ts +11 -0
  78. package/src/prompts/system/memory-extraction-system.md +5 -22
  79. package/src/prompts/system/system-prompt.md +1 -1
  80. package/src/session/agent-session.ts +52 -6
  81. package/src/session/messages.ts +6 -0
  82. package/src/session/prewalk.ts +25 -7
  83. package/src/session/session-entries.ts +0 -1
  84. package/src/session/session-maintenance.ts +10 -1
  85. package/src/session/session-manager.ts +15 -0
  86. package/src/session/session-stats.ts +24 -3
  87. package/src/session/settings-stream-fn.ts +7 -0
  88. package/src/session/skill-title-input.ts +32 -0
  89. package/src/session/turn-recovery.ts +23 -18
  90. package/src/subprocess/worker-client.ts +8 -5
  91. package/src/task/executor.ts +11 -0
  92. package/src/task/index.ts +2 -0
  93. package/src/task/label.ts +14 -1
  94. package/src/task/persisted-revive.ts +13 -0
  95. package/src/task/render.ts +1 -1
  96. package/src/task/structured-subagent.ts +5 -2
  97. package/src/tiny/completion-prompt.ts +16 -0
  98. package/src/tiny/title-client.ts +15 -6
  99. package/src/tiny/title-protocol.ts +8 -1
  100. package/src/tiny/worker.ts +21 -19
  101. package/src/tools/bash.ts +7 -1
  102. package/src/tools/read-format.ts +16 -2
  103. package/src/tools/read-summary.ts +9 -4
  104. package/src/tools/read.ts +306 -72
  105. package/src/utils/block-context.ts +15 -1
  106. package/src/utils/fetch-timeout.ts +33 -0
  107. package/src/utils/git.ts +54 -11
  108. package/src/web/search/providers/browser-page.ts +21 -3
  109. package/src/web/search/providers/tinyfish.ts +26 -0
package/src/tools/read.ts CHANGED
@@ -10,7 +10,15 @@ import type {
10
10
  ToolTier,
11
11
  } from "@oh-my-pi/pi-agent-core";
12
12
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
13
- import { type ImageMetadata, isProbablyBinary, logger, prompt, readImageMetadata } from "@oh-my-pi/pi-utils";
13
+ import {
14
+ BINARY_SNIFF_BYTES,
15
+ type ImageMetadata,
16
+ isProbablyBinary,
17
+ isProbablyBinaryHeader,
18
+ logger,
19
+ prompt,
20
+ readImageMetadata,
21
+ } from "@oh-my-pi/pi-utils";
14
22
  import {
15
23
  canonicalSnapshotKey,
16
24
  getFileSnapshotStore,
@@ -85,6 +93,7 @@ import {
85
93
  formatTextWithMode,
86
94
  type HashlineHeaderContext,
87
95
  hashlineHeaderContext,
96
+ hashlineHeaderContextForText,
88
97
  lineNumbersFromSpans,
89
98
  markMarkdownContentType,
90
99
  prependHashlineHeader,
@@ -117,15 +126,198 @@ export { readToolRenderer } from "./read-renderer";
117
126
  const MAX_PROFILE_SUMMARY_BYTES = 32 * 1024 * 1024;
118
127
  const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
119
128
 
120
- async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise<string[] | undefined> {
121
- if (fileSize > SNAPSHOT_MAX_BYTES) return undefined;
129
+ /** LF byte, scanned natively to find line boundaries in a buffered file. */
130
+ const LF_BYTE = 0x0a;
131
+
132
+ /**
133
+ * Whole-file bytes plus every view the local text read path consumes,
134
+ * materialized exactly once.
135
+ *
136
+ * The binary sniff, the structural summary, the emitted line window, bracket
137
+ * context and the snapshot hash all want the same bytes. Each used to open the
138
+ * file for itself, so a single ranged read cost up to four opens, three UTF-8
139
+ * decodes and two CRLF normalization passes over identical content.
140
+ *
141
+ * Only files at or below {@link SNAPSHOT_MAX_BYTES} are buffered: past that cap
142
+ * bracket context and the snapshot are skipped anyway, so streaming a window
143
+ * stays strictly cheaper than materializing the file.
144
+ */
145
+ interface BufferedFileText {
146
+ /** File bytes, verbatim. */
147
+ readonly bytes: Buffer;
148
+ /** Verbatim UTF-8 decode: a leading BOM and CRLF line endings both survive. */
149
+ readonly rawText: string;
150
+ /** {@link rawText} split on LF, CR retained, so segments stay byte-faithful. */
151
+ readonly rawSegments: readonly string[];
152
+ /** BOM-stripped, CRLF-preserving text: what `Bun.file(path).text()` returns. */
153
+ readonly strippedText: string;
154
+ /** {@link strippedText} normalized to LF — the exact text the snapshot store hashes. */
155
+ readonly normalizedText: string;
156
+ /** Addressable lines of {@link normalizedText}; bracket context indexes these. */
157
+ readonly addressableLines: readonly string[];
158
+ /** Whether the final byte is LF. */
159
+ readonly endsWithNewline: boolean;
160
+ }
161
+
162
+ /**
163
+ * Read the whole file, or `undefined` when the bytes cannot be read — which
164
+ * drops the caller back to the streaming reader and reproduces today's error
165
+ * surface.
166
+ *
167
+ * Kept separate from {@link deriveBufferedFileText} so the binary sniff can run
168
+ * on the bytes first: a file that decodes to mojibake is refused, and building
169
+ * three string views of it before finding that out would be pure waste.
170
+ */
171
+ async function readWholeFile(absolutePath: string): Promise<Buffer | undefined> {
122
172
  try {
123
- return splitAddressableFileLines(normalizeToLF(await Bun.file(absolutePath).text()));
173
+ return await fs.readFile(absolutePath);
124
174
  } catch {
125
175
  return undefined;
126
176
  }
127
177
  }
128
178
 
179
+ /**
180
+ * Derive every view of `bytes` the read path needs, decoding exactly once.
181
+ *
182
+ * `Bun.file(path).text()` strips a leading BOM while `Buffer.toString` keeps it,
183
+ * and the snapshot store plus the patcher's live-file read both go through the
184
+ * stripping decoder. {@link BufferedFileText.strippedText} therefore reproduces
185
+ * that decode for hashing while {@link BufferedFileText.rawText} stays verbatim
186
+ * for the emitted lines and their byte accounting.
187
+ */
188
+ function deriveBufferedFileText(bytes: Buffer): BufferedFileText {
189
+ const rawText = bytes.toString("utf-8");
190
+ const strippedText = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText;
191
+ // `normalizeToLF` allocates a copy; skip it outright for the common LF file.
192
+ const normalizedText = strippedText.includes("\r") ? normalizeToLF(strippedText) : strippedText;
193
+ const rawSegments = rawText.split("\n");
194
+ let addressableLines: readonly string[];
195
+ if (normalizedText === rawText) {
196
+ // Nothing was rewritten, so display and bracket context share one array;
197
+ // the terminal newline sentinel is dropped exactly as
198
+ // `splitAddressableFileLines` does.
199
+ const last = rawSegments.length - 1;
200
+ addressableLines = last > 0 && rawSegments[last] === "" ? rawSegments.slice(0, last) : rawSegments;
201
+ } else {
202
+ addressableLines = splitAddressableFileLines(normalizedText);
203
+ }
204
+ return {
205
+ bytes,
206
+ rawText,
207
+ rawSegments,
208
+ strippedText,
209
+ normalizedText,
210
+ addressableLines,
211
+ endsWithNewline: bytes.length > 0 && bytes[bytes.length - 1] === LF_BYTE,
212
+ };
213
+ }
214
+
215
+ /** The line window a range read renders, with the budget accounting behind it. */
216
+ interface ReadLineWindow {
217
+ lines: string[];
218
+ totalFileLines: number;
219
+ collectedBytes: number;
220
+ stoppedByByteLimit: boolean;
221
+ firstLinePreview?: { text: string; bytes: number };
222
+ firstLineByteLength?: number;
223
+ /** Whether the fully scanned source ended in a newline. */
224
+ hasTrailingNewline: boolean;
225
+ /** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */
226
+ reachedEof: boolean;
227
+ }
228
+
229
+ /**
230
+ * Slice the window {@link streamLinesFromFile} would have collected out of an
231
+ * already-buffered file, under the identical line and byte budgets.
232
+ *
233
+ * Line byte lengths are walked out of the buffer rather than measured on the
234
+ * decoded strings: a file that is not valid UTF-8 decodes to U+FFFD, whose
235
+ * encoded length differs from the bytes on disk, and those lengths decide both
236
+ * the reported byte counts and where truncation lands.
237
+ */
238
+ function collectLineWindowFromBuffer(
239
+ file: BufferedFileText,
240
+ startLine: number,
241
+ maxLinesToCollect: number,
242
+ maxBytes: number,
243
+ selectedLineLimit: number,
244
+ includeTerminalNewline: boolean,
245
+ ): ReadLineWindow {
246
+ const { bytes, rawSegments, endsWithNewline } = file;
247
+ // A trailing LF closes the last line rather than opening an empty one, except
248
+ // in raw mode where that terminal sentinel is addressable.
249
+ const totalFileLines =
250
+ endsWithNewline && !includeTerminalNewline && rawSegments.length > 1
251
+ ? rawSegments.length - 1
252
+ : rawSegments.length;
253
+ const window: ReadLineWindow = {
254
+ lines: [],
255
+ totalFileLines,
256
+ collectedBytes: 0,
257
+ stoppedByByteLimit: false,
258
+ hasTrailingNewline: endsWithNewline,
259
+ reachedEof: true,
260
+ };
261
+ if (startLine >= totalFileLines) return window;
262
+
263
+ let lineStart = 0;
264
+ for (let index = 0; index < startLine; index++) {
265
+ const newlineAt = bytes.indexOf(LF_BYTE, lineStart);
266
+ if (newlineAt === -1) {
267
+ lineStart = bytes.length;
268
+ break;
269
+ }
270
+ lineStart = newlineAt + 1;
271
+ }
272
+
273
+ let doneCollecting = false;
274
+ let selectedLinesSeen = 0;
275
+ for (let index = startLine; index < totalFileLines; index++) {
276
+ const newlineAt = bytes.indexOf(LF_BYTE, lineStart);
277
+ const lineEnd = newlineAt === -1 ? bytes.length : newlineAt;
278
+ const lineByteLength = lineEnd - lineStart;
279
+
280
+ if (selectedLinesSeen < selectedLineLimit) selectedLinesSeen++;
281
+ // Preview covers the first selected line only, capped at the byte budget:
282
+ // the oversized-first-line branch renders it when no full line fits.
283
+ if (window.lines.length === 0 && window.firstLinePreview === undefined && lineByteLength > 0) {
284
+ const previewEnd = Math.min(lineEnd, lineStart + maxBytes);
285
+ const { text, bytes: previewBytes } = truncateHeadBytes(bytes.subarray(lineStart, previewEnd), maxBytes);
286
+ window.firstLinePreview = { text, bytes: previewBytes };
287
+ }
288
+
289
+ if (!doneCollecting) {
290
+ const separatorBytes = window.lines.length > 0 ? 1 : 0;
291
+ if (window.lines.length >= maxLinesToCollect) {
292
+ doneCollecting = true;
293
+ } else if (window.lines.length === 0 && lineByteLength > maxBytes) {
294
+ window.stoppedByByteLimit = true;
295
+ doneCollecting = true;
296
+ window.firstLineByteLength ??= lineByteLength;
297
+ } else if (window.lines.length > 0 && window.collectedBytes + separatorBytes + lineByteLength > maxBytes) {
298
+ window.stoppedByByteLimit = true;
299
+ doneCollecting = true;
300
+ } else {
301
+ window.lines.push(rawSegments[index] ?? "");
302
+ window.collectedBytes += separatorBytes + lineByteLength;
303
+ window.firstLineByteLength ??= lineByteLength;
304
+ if (window.collectedBytes > maxBytes) {
305
+ window.stoppedByByteLimit = true;
306
+ doneCollecting = true;
307
+ } else if (window.lines.length >= maxLinesToCollect) {
308
+ doneCollecting = true;
309
+ }
310
+ }
311
+ } else if (window.firstLineByteLength === undefined) {
312
+ window.firstLineByteLength = lineByteLength;
313
+ }
314
+
315
+ if (doneCollecting && selectedLinesSeen >= selectedLineLimit) break;
316
+ lineStart = lineEnd + 1;
317
+ }
318
+ return window;
319
+ }
320
+
129
321
  interface StreamFileLinesOptions {
130
322
  includeTerminalNewline?: boolean;
131
323
  stopScanAfterCollect?: boolean;
@@ -139,19 +331,7 @@ async function streamLinesFromFile(
139
331
  selectedLineLimit: number | null,
140
332
  signal?: AbortSignal,
141
333
  options: StreamFileLinesOptions = {},
142
- ): Promise<{
143
- lines: string[];
144
- totalFileLines: number;
145
- collectedBytes: number;
146
- stoppedByByteLimit: boolean;
147
- firstLinePreview?: { text: string; bytes: number };
148
- firstLineByteLength?: number;
149
- selectedBytesTotal: number;
150
- /** Whether the fully scanned source ended in a newline. */
151
- hasTrailingNewline: boolean;
152
- /** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */
153
- reachedEof: boolean;
154
- }> {
334
+ ): Promise<ReadLineWindow> {
155
335
  const { includeTerminalNewline = false, stopScanAfterCollect = false } = options;
156
336
  const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
157
337
  const collectedLines: string[] = [];
@@ -168,7 +348,6 @@ async function streamLinesFromFile(
168
348
  let firstLinePreviewBytes = 0;
169
349
  const firstLinePreviewChunks: Buffer[] = [];
170
350
  let firstLineByteLength: number | undefined;
171
- let selectedBytesTotal = 0;
172
351
  let selectedLinesSeen = 0;
173
352
  let captureLine = false;
174
353
  let discardLineChunks = false;
@@ -219,7 +398,6 @@ async function streamLinesFromFile(
219
398
 
220
399
  const finalizeLine = () => {
221
400
  if (lineIndex >= startLine && (selectedLineLimit === null || selectedLinesSeen < selectedLineLimit)) {
222
- selectedBytesTotal += currentLineLength + (selectedLinesSeen > 0 ? 1 : 0);
223
401
  selectedLinesSeen++;
224
402
  }
225
403
 
@@ -337,7 +515,6 @@ async function streamLinesFromFile(
337
515
  stoppedByByteLimit,
338
516
  firstLinePreview,
339
517
  firstLineByteLength,
340
- selectedBytesTotal,
341
518
  reachedEof,
342
519
  hasTrailingNewline: reachedEof && endedWithNewline,
343
520
  };
@@ -659,15 +836,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
659
836
  }
660
837
 
661
838
  /**
662
- * Stream multiple non-contiguous ranges from a local file. ACP bridge takes
663
- * priority when present (editor buffer is source of truth); otherwise each
664
- * range is streamed independently with its own line/byte budget. Out-of-bounds
665
- * ranges surface as inline notices rather than aborting the read.
839
+ * Render multiple non-contiguous ranges of a local file. ACP bridge takes
840
+ * priority when present (editor buffer is source of truth); otherwise ranges
841
+ * are sliced out of `buffered` when the caller already materialized the file,
842
+ * and streamed independently with their own line/byte budget when it did not.
843
+ * Out-of-bounds ranges surface as inline notices rather than aborting the read.
666
844
  */
667
845
  async #readLocalFileMultiRange(
668
846
  absolutePath: string,
669
847
  ranges: readonly LineRange[],
670
848
  fileSize: number,
849
+ buffered: BufferedFileText | undefined,
671
850
  parsed: ParsedSelector,
672
851
  displayMode: { hashLines: boolean; lineNumbers: boolean },
673
852
  suffixResolution: { from: string; to: string } | undefined,
@@ -715,7 +894,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
715
894
  const notices: string[] = [];
716
895
  const visibleSpans: Array<{ startLine: number; endLine: number }> = [];
717
896
  const displayLineByNumber = new Map<number, string>();
718
- const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize);
897
+ const fullLines = rawSelector ? undefined : buffered?.addressableLines;
719
898
  let columnTruncated = 0;
720
899
  let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
721
900
 
@@ -724,27 +903,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
724
903
  const requestedLength = range.endLine !== undefined ? range.endLine - range.startLine + 1 : this.#defaultLimit;
725
904
  const maxLines = Math.min(requestedLength, DEFAULT_MAX_LINES);
726
905
 
727
- // When the full file is already in memory (the common case for files
728
- // within the snapshot byte cap), slice ranges from it instead of
729
- // re-streaming the file once per range.
906
+ // The file is already in memory for everything within the snapshot byte
907
+ // cap, so slice ranges out of it instead of re-streaming per range. Raw
908
+ // mode cannot use the addressable lines (it keeps CR bytes and the
909
+ // terminal newline sentinel) but still slices the same buffer.
730
910
  let collectedLines: string[];
731
911
  let totalFileLines: number;
912
+ const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512);
732
913
  if (fullLines) {
733
914
  totalFileLines = fullLines.length;
734
915
  collectedLines = fullLines.slice(rangeStart, rangeStart + maxLines);
735
916
  } else {
736
- const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512);
737
- const streamResult = await streamLinesFromFile(
738
- absolutePath,
739
- rangeStart,
740
- maxLines,
741
- maxBytesForRead,
742
- maxLines,
743
- signal,
744
- { includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES },
745
- );
746
- totalFileLines = streamResult.totalFileLines;
747
- collectedLines = streamResult.lines;
917
+ const window = buffered
918
+ ? collectLineWindowFromBuffer(buffered, rangeStart, maxLines, maxBytesForRead, maxLines, rawSelector)
919
+ : await streamLinesFromFile(absolutePath, rangeStart, maxLines, maxBytesForRead, maxLines, signal, {
920
+ includeTerminalNewline: rawSelector,
921
+ stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES,
922
+ });
923
+ totalFileLines = window.totalFileLines;
924
+ collectedLines = window.lines;
748
925
  }
749
926
 
750
927
  if (rangeStart >= totalFileLines) {
@@ -786,7 +963,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
786
963
  const entries = buildLineEntriesWithBlockContext(
787
964
  fullLines,
788
965
  visibleSpans,
789
- { path: absolutePath },
966
+ { path: absolutePath, text: buffered?.normalizedText },
790
967
  {
791
968
  lineText: (lineNumber, sourceText) => {
792
969
  const visibleText = displayLineByNumber.get(lineNumber);
@@ -811,14 +988,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
811
988
  outputText = blocks.join("\n\n…\n\n");
812
989
  }
813
990
  if (shouldAddHashLines && outputText) {
814
- const tag = await recordFileSnapshot(this.session, absolutePath);
991
+ const tag = buffered
992
+ ? getFileSnapshotStore(this.session).record(canonicalSnapshotKey(absolutePath), buffered.normalizedText)
993
+ : await recordFileSnapshot(this.session, absolutePath);
815
994
  if (tag) {
816
995
  recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
817
996
  outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
818
997
  }
819
998
  } else if (rawSelector && visibleSpans.length > 0) {
820
999
  const rawSeenLines = lineNumbersFromSpans(visibleSpans);
821
- if (rawSeenLines.length > 0) await recordFileSnapshot(this.session, absolutePath, rawSeenLines);
1000
+ if (rawSeenLines.length > 0) {
1001
+ if (buffered) {
1002
+ getFileSnapshotStore(this.session).record(
1003
+ canonicalSnapshotKey(absolutePath),
1004
+ buffered.normalizedText,
1005
+ rawSeenLines,
1006
+ );
1007
+ } else {
1008
+ await recordFileSnapshot(this.session, absolutePath, rawSeenLines);
1009
+ }
1010
+ }
822
1011
  }
823
1012
  if (notices.length > 0) {
824
1013
  outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
@@ -1161,6 +1350,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1161
1350
  content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
1162
1351
  }
1163
1352
  } else {
1353
+ // One read for every consumer below. The sniff, the structural summary,
1354
+ // the rendered window, bracket context and the snapshot hash all want
1355
+ // the same bytes; past the snapshot cap nothing wants the whole file,
1356
+ // so the streaming reader keeps that case cheap.
1357
+ const wholeFileBytes = fileSize <= SNAPSHOT_MAX_BYTES ? await readWholeFile(absolutePath) : undefined;
1358
+
1164
1359
  // Binary sniff before any UTF-8 text materialization. A binary file
1165
1360
  // (font, object, archive, packed blob) decodes to NUL/control bytes and
1166
1361
  // U+FFFD mojibake that corrupts the terminal and burns context. Images,
@@ -1168,7 +1363,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1168
1363
  // everything reaching here is meant to be plain text. `:raw` stays the
1169
1364
  // explicit escape hatch for reading bytes verbatim. This single guard
1170
1365
  // covers both the multi-range and single-range disk paths below.
1171
- if (!isRawSelector(parsed) && (await isProbablyBinary(absolutePath))) {
1366
+ const looksBinary =
1367
+ !isRawSelector(parsed) &&
1368
+ (wholeFileBytes
1369
+ ? isProbablyBinaryHeader(wholeFileBytes.subarray(0, BINARY_SNIFF_BYTES))
1370
+ : await isProbablyBinary(absolutePath));
1371
+ if (looksBinary) {
1172
1372
  return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1173
1373
  .text(
1174
1374
  prependSuffixResolutionNotice(
@@ -1179,13 +1379,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1179
1379
  .sourcePath(absolutePath)
1180
1380
  .done();
1181
1381
  }
1382
+ // Decode only what survived the sniff.
1383
+ const buffered = wholeFileBytes ? deriveBufferedFileText(wholeFileBytes) : undefined;
1182
1384
 
1183
1385
  if (
1184
1386
  parsed.kind === "none" &&
1185
1387
  this.session.settings.get("read.summarize.enabled") &&
1186
1388
  (this.session.settings.get("read.summarize.prose") || !isProseSummaryPath(absolutePath))
1187
1389
  ) {
1188
- const summary = await trySummarize(this.session, absolutePath, fileSize, signal);
1390
+ const summary = await trySummarize(this.session, absolutePath, fileSize, signal, buffered?.strippedText);
1189
1391
  if (summary?.parsed && summary.elided) {
1190
1392
  const renderedSummary = renderSummary(this.session, summary);
1191
1393
  const footer = formatSummaryElisionFooter(
@@ -1194,7 +1396,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1194
1396
  renderedSummary.elidedLines,
1195
1397
  );
1196
1398
  const summaryHashContext = displayMode.hashLines
1197
- ? await readHashlineHeaderContext(this.session, absolutePath, this.session.cwd)
1399
+ ? buffered
1400
+ ? hashlineHeaderContextForText(
1401
+ this.session,
1402
+ absolutePath,
1403
+ this.session.cwd,
1404
+ buffered.normalizedText,
1405
+ )
1406
+ : await readHashlineHeaderContext(this.session, absolutePath, this.session.cwd)
1198
1407
  : undefined;
1199
1408
  const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
1200
1409
  const modelText = prependHashlineHeader(bodyText, summaryHashContext);
@@ -1221,6 +1430,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1221
1430
  absolutePath,
1222
1431
  parsed.ranges,
1223
1432
  fileSize,
1433
+ buffered,
1224
1434
  parsed,
1225
1435
  displayMode,
1226
1436
  suffixResolution,
@@ -1285,15 +1495,24 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1285
1495
  // Assume ~512 bytes/line average; never go below the shared default.
1286
1496
  const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1287
1497
 
1288
- const streamResult = await streamLinesFromFile(
1289
- absolutePath,
1290
- startLine,
1291
- maxLinesToCollect,
1292
- maxBytesForRead,
1293
- selectedLineLimit,
1294
- undefined, // plain-file read: deterministic and fast, never abort mid-read
1295
- { includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES },
1296
- );
1498
+ const lineWindow = buffered
1499
+ ? collectLineWindowFromBuffer(
1500
+ buffered,
1501
+ startLine,
1502
+ maxLinesToCollect,
1503
+ maxBytesForRead,
1504
+ selectedLineLimit,
1505
+ rawSelector,
1506
+ )
1507
+ : await streamLinesFromFile(
1508
+ absolutePath,
1509
+ startLine,
1510
+ maxLinesToCollect,
1511
+ maxBytesForRead,
1512
+ selectedLineLimit,
1513
+ undefined, // plain-file read: deterministic and fast, never abort mid-read
1514
+ { includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES },
1515
+ );
1297
1516
 
1298
1517
  const {
1299
1518
  lines: collectedLines,
@@ -1304,7 +1523,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1304
1523
  firstLineByteLength,
1305
1524
  reachedEof,
1306
1525
  hasTrailingNewline,
1307
- } = streamResult;
1526
+ } = lineWindow;
1308
1527
 
1309
1528
  // Check if offset is out of bounds - return graceful message instead of throwing
1310
1529
  if (requestedStart >= totalFileLines) {
@@ -1347,9 +1566,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1347
1566
  for (let i = 0; i < displayLines.length; i++) {
1348
1567
  displayLineByNumber.set(startLineDisplay + i, displayLines[i] ?? "");
1349
1568
  }
1350
- const bracketContextFullLines = rawSelector
1351
- ? undefined
1352
- : await readBracketContextFullLines(absolutePath, fileSize);
1569
+ const bracketContextFullLines = rawSelector ? undefined : buffered?.addressableLines;
1353
1570
  const displayedEndLine = startLineDisplay + Math.max(0, displayLines.length - 1);
1354
1571
 
1355
1572
  const selectedContent = displayLines.join("\n");
@@ -1376,17 +1593,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1376
1593
  const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1377
1594
  let hashContext: HashlineHeaderContext | undefined;
1378
1595
  if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
1379
- // The tag is a content hash of the WHOLE file. A whole-file read
1380
- // already holds every line in memory; a range read re-reads the
1381
- // file (bounded by SNAPSHOT_MAX_BYTES) so the tag fingerprints the
1382
- // full file and any anchor validates while the file is unchanged.
1596
+ // The tag is a content hash of the WHOLE file, so any anchor the
1597
+ // model returns validates while the live file is unchanged. The
1598
+ // buffered text is that whole file; above the snapshot cap only a
1599
+ // non-truncated whole-file window can supply it.
1383
1600
  const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
1384
- const tag = isWholeFile
1601
+ const tag = buffered
1385
1602
  ? getFileSnapshotStore(this.session).record(
1386
1603
  canonicalSnapshotKey(absolutePath),
1387
- normalizeToLF(`${collectedLines.join("\n")}${hasTrailingNewline ? "\n" : ""}`),
1604
+ buffered.normalizedText,
1388
1605
  )
1389
- : await recordFileSnapshot(this.session, absolutePath);
1606
+ : isWholeFile
1607
+ ? getFileSnapshotStore(this.session).record(
1608
+ canonicalSnapshotKey(absolutePath),
1609
+ normalizeToLF(`${collectedLines.join("\n")}${hasTrailingNewline ? "\n" : ""}`),
1610
+ )
1611
+ : await recordFileSnapshot(this.session, absolutePath);
1390
1612
  if (tag) {
1391
1613
  hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
1392
1614
  }
@@ -1413,7 +1635,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1413
1635
  const entries = buildLineEntriesWithBlockContext(
1414
1636
  bracketContextFullLines,
1415
1637
  [{ startLine: startLineDisplay, endLine: displayedEndLine }],
1416
- { path: absolutePath },
1638
+ { path: absolutePath, text: buffered?.normalizedText },
1417
1639
  {
1418
1640
  lineText: (lineNumber, sourceText) => {
1419
1641
  const visibleText = displayLineByNumber.get(lineNumber);
@@ -1500,11 +1722,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1500
1722
  recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
1501
1723
  }
1502
1724
  if (rawSelector && !firstLineExceedsLimit && collectedLines.length > 0) {
1503
- await recordFileSnapshot(
1504
- this.session,
1505
- absolutePath,
1506
- contiguousLineNumbers(startLineDisplay, collectedLines.length),
1507
- );
1725
+ // A raw read emits no header, but recording the range it displayed
1726
+ // lets a same-content hashline tag inherit its provenance.
1727
+ const seenLines = contiguousLineNumbers(startLineDisplay, collectedLines.length);
1728
+ if (buffered) {
1729
+ getFileSnapshotStore(this.session).record(
1730
+ canonicalSnapshotKey(absolutePath),
1731
+ buffered.normalizedText,
1732
+ seenLines,
1733
+ );
1734
+ } else {
1735
+ await recordFileSnapshot(this.session, absolutePath, seenLines);
1736
+ }
1508
1737
  }
1509
1738
 
1510
1739
  if (capturedDisplayContent) {
@@ -1685,10 +1914,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1685
1914
  const rawSelector = isRawSelector(parsedSel);
1686
1915
  const displayMode = resolveFileDisplayMode(this.session, { raw: rawSelector, immutable: true });
1687
1916
  if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
1917
+ // Bracket context and per-range slicing both want the whole artifact, so
1918
+ // materialize it once exactly as the plain-file path does.
1919
+ const artifactBytes = artifact.size <= SNAPSHOT_MAX_BYTES ? await readWholeFile(artifact.path) : undefined;
1920
+ const buffered = artifactBytes ? deriveBufferedFileText(artifactBytes) : undefined;
1688
1921
  const read = await this.#readLocalFileMultiRange(
1689
1922
  artifact.path,
1690
1923
  parsedSel.ranges,
1691
1924
  artifact.size,
1925
+ buffered,
1692
1926
  parsedSel,
1693
1927
  displayMode,
1694
1928
  undefined,
@@ -22,6 +22,20 @@ export interface LineSpan {
22
22
  export interface BlockContextSource {
23
23
  path?: string;
24
24
  lang?: string;
25
+ /**
26
+ * The whole source `fullLines` was split from, when the caller still holds it.
27
+ * Supplying it skips re-joining every line into a fresh whole-file string on
28
+ * the way to the parser. It MUST be the same content as `fullLines`; a
29
+ * differing trailing newline is the only tolerated variation, since it moves
30
+ * no node's line number.
31
+ *
32
+ * Every current supplier derives both from one buffer in the same breath, so
33
+ * the two cannot drift. Do NOT set it on a source object that is reused
34
+ * across two different line arrays — a before/after diff pair, say — because
35
+ * the boundary lines tree-sitter reports would then be indexed into the wrong
36
+ * array and surface off-by-N context rows.
37
+ */
38
+ text?: string;
25
39
  }
26
40
 
27
41
  export type LineEntry = { kind: "line"; lineNumber: number; text: string; context: boolean } | { kind: "ellipsis" };
@@ -105,7 +119,7 @@ function nativeBlockContext(
105
119
  let boundaries: number[] | null;
106
120
  try {
107
121
  boundaries = enclosingBlockBoundaries({
108
- code: fullLines.join("\n"),
122
+ code: source.text ?? fullLines.join("\n"),
109
123
  path: source.path,
110
124
  lang: source.lang,
111
125
  ranges,
@@ -8,3 +8,36 @@ export function withTimeoutSignal(timeoutMs: number, signal?: AbortSignal): Abor
8
8
  export function isTimeoutError(error: unknown): boolean {
9
9
  return error instanceof Error && error.name === "TimeoutError";
10
10
  }
11
+
12
+ /**
13
+ * Proxy environment variables Bun's `fetch` consults, in the precedence order it
14
+ * reads them. Used to name the offending entry in {@link unsupportedProxyMessage}.
15
+ */
16
+ const PROXY_ENV_VARS = ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy"] as const;
17
+
18
+ /**
19
+ * Detect Bun's `UnsupportedProxyProtocol` fetch rejection, raised when a proxy
20
+ * env var (`HTTPS_PROXY`, `ALL_PROXY`, …) points at a scheme it cannot drive —
21
+ * most commonly a SOCKS proxy (`socks5://`, `socks5h://`). The raw error tells
22
+ * the caller to "pass `verbose: true` in the second argument to fetch()", which
23
+ * is meaningless from a CLI, so callers translate it into
24
+ * {@link unsupportedProxyMessage}.
25
+ */
26
+ export function isUnsupportedProxyError(error: unknown): boolean {
27
+ return error instanceof Error && error.message.includes("UnsupportedProxyProtocol");
28
+ }
29
+
30
+ /**
31
+ * Build an actionable CLI message for an {@link isUnsupportedProxyError} failure,
32
+ * naming any set proxy env var whose scheme is not `http(s)://` so the user can
33
+ * see exactly which variable to change.
34
+ */
35
+ export function unsupportedProxyMessage(env: Record<string, string | undefined> = process.env): string {
36
+ const offending: string[] = [];
37
+ for (const name of PROXY_ENV_VARS) {
38
+ const value = env[name];
39
+ if (value && !/^https?:\/\//i.test(value)) offending.push(`${name}=${value}`);
40
+ }
41
+ const detail = offending.length > 0 ? ` (offending: ${offending.join(", ")})` : "";
42
+ return `Proxy configuration uses a scheme Bun's fetch cannot use${detail}. Only http:// and https:// proxies are supported — SOCKS proxies (socks5://, socks5h://) are not. Point HTTP_PROXY/HTTPS_PROXY at an http:// proxy URL or unset the proxy variables, then retry.`;
43
+ }