pi-supernova 0.5.0 → 0.6.0

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.
@@ -3,13 +3,14 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
6
+ import { truncateChars } from "../output/format.js";
6
7
  import { isString, isNumber, isFunction, isObject, looksLikePath } from "../shared/decode.js";
7
8
  import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
8
9
  import { unknownToolMessage } from "./catalog.js";
9
10
  import { extractStructuralSurface } from "../context/surface.js";
10
11
  import { pickSpan } from "../context/spans.js";
11
12
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
12
- import { executeSnap } from "../context/snap.js";
13
+ import { executeSnap, tokenizeQuery, stem } from "../context/snap.js";
13
14
  import { selectEvidence } from "../context/evidence.js";
14
15
  import { WorkspaceIndex } from "../context/repo-index.js";
15
16
  import { outlineFile } from "../context/outline.js";
@@ -58,13 +59,58 @@ function unwrapIfFullyQuoted(s) {
58
59
  return inner;
59
60
  }
60
61
 
61
- function sliceLines(text, offset, limit) {
62
- if (!isNumber(offset) && !isNumber(limit)) return text;
63
- const lines = text.split("\n");
62
+ function sliceLinesRawInfo(text, offset, limit) {
63
+ const logical = contentLineInfo(text).count;
64
+ const totalLines = text === "" ? 1 : logical + (text.endsWith("\n") ? 1 : 0);
65
+
66
+ if (!isNumber(offset) && !isNumber(limit)) {
67
+ return { text, end: totalLines, total: totalLines, count: totalLines, eof: true, whole: true };
68
+ }
69
+
64
70
  const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
65
- const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
71
+ const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : totalLines;
72
+
73
+ if (count === 0 || startIndex >= totalLines) {
74
+ const emptyFile = totalLines === 1 && text === "";
75
+
76
+ return { text: "", end: totalLines, total: totalLines, count: 0, eof: true, whole: emptyFile };
77
+ }
78
+
79
+ const endExclusive = Math.min(totalLines, startIndex + count);
80
+ const start = lineStartIndex(text, startIndex + 1);
81
+ const end = lineEndIndex(text, start, endExclusive - startIndex);
82
+ let selected = text.slice(start, end);
83
+ const eof = endExclusive >= totalLines || (endExclusive === totalLines - 1 && text.endsWith("\n"));
84
+
85
+ if (endExclusive < totalLines && !selected.endsWith("\n")) selected += "\n";
86
+
87
+ return { text: selected, end: endExclusive, total: totalLines, count: endExclusive - startIndex, eof, whole: startIndex === 0 && eof };
88
+ }
89
+
90
+ /** Read-window slicing preserves the selected lines' own line ending. */
91
+ function sliceLinesRaw(text, offset, limit) {
92
+ return sliceLinesRawInfo(text, offset, limit).text;
93
+ }
94
+
95
+ function readLineParam(value, name) {
96
+ if (value === undefined) return undefined;
97
+ const number = isNumber(value) ? value : isString(value) && value.trim() !== "" ? Number(value) : NaN;
98
+
99
+ if (!Number.isFinite(number)) throw new Error("read " + name + " must be a finite number");
100
+
101
+ return Math.floor(number);
102
+ }
103
+
104
+ function normalizeReadWindow(params) {
105
+ if (!isObject(params)) return params;
106
+ const normalized = { ...params };
107
+ const offset = readLineParam(params.offset, "offset");
108
+ const limit = readLineParam(params.limit, "limit");
66
109
 
67
- return lines.slice(startIndex, startIndex + count).join("\n");
110
+ if (offset !== undefined) normalized.offset = Math.max(1, offset);
111
+ if (limit !== undefined) normalized.limit = Math.max(0, limit);
112
+
113
+ return normalized;
68
114
  }
69
115
 
70
116
  function resolveReadPath(cwd, target) {
@@ -77,12 +123,16 @@ function resolveReadPath(cwd, target) {
77
123
  async function probeExistingPath(cwd, targetParam, vfs) {
78
124
  const targetPath = resolveReadPath(cwd, targetParam);
79
125
 
80
- if (vfs.getOverlay(targetPath) !== undefined) return { path: targetPath, directory: false };
126
+ if (vfs.getOverlay(targetPath) !== undefined) {
127
+ const overlay = vfs.getOverlay(targetPath);
128
+
129
+ return { path: targetPath, directory: false, size: Buffer.byteLength(overlay, "utf8"), overlay };
130
+ }
81
131
 
82
132
  try {
83
133
  const st = await fs.stat(targetPath);
84
134
 
85
- return { path: targetPath, directory: st.isDirectory() };
135
+ return { path: targetPath, directory: st.isDirectory(), size: st.size };
86
136
  } catch (err) {
87
137
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
88
138
 
@@ -94,6 +144,8 @@ async function probeExistingPath(cwd, targetParam, vfs) {
94
144
 
95
145
  const EDIT_PREVIEW_LINES = 16;
96
146
 
147
+ const MAX_DIRECTORY_ENTRIES = 10000;
148
+
97
149
  function sourceLines(content) {
98
150
  const raw = content.split("\n");
99
151
 
@@ -115,12 +167,11 @@ function formatNumberedLine(n, text) {
115
167
  }
116
168
 
117
169
  function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
118
- const lines = sourceLines(content);
170
+ const { count, preview } = contentLineInfo(content, cap);
119
171
 
120
- if (lines.length === 0) return "0 lines";
121
- const shown = lines.slice(0, cap);
122
- const body = shown.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
123
- const suffix = lines.length > cap ? lines.length + " lines total" : lines.length + " lines";
172
+ if (count === 0) return "0 lines";
173
+ const body = preview.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
174
+ const suffix = count + " lines total";
124
175
 
125
176
  return body + "\n" + suffix;
126
177
  }
@@ -134,22 +185,28 @@ function applyReplacements(target, content, requestedEdits) {
134
185
  }
135
186
 
136
187
  if (!isString(replacement?.newText)) throw new Error("edit requires newText");
137
- const index = content.indexOf(replacement.oldText);
188
+ const oldText = String(replacement.oldText);
189
+ const newText = String(replacement.newText);
190
+ const index = content.indexOf(oldText);
138
191
 
139
192
  if (index < 0) {
140
193
  throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
141
194
  }
142
- const second = content.indexOf(replacement.oldText, index + 1);
195
+ const second = content.indexOf(oldText, index + 1);
143
196
 
144
197
  if (second >= 0) {
145
- const lines = sourceLines(content);
146
198
  const a = lineNumberAt(content, index);
147
199
  const b = lineNumberAt(content, second);
200
+ const lineText = n => {
201
+ const range = lineTextRange(content, n);
202
+
203
+ return content.slice(range.start, range.end).replace(/\r?\n$/, "");
204
+ };
148
205
 
149
- throw new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lines[a - 1] ?? "") + "\n" + formatNumberedLine(b, lines[b - 1] ?? ""));
206
+ throw new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lineText(a)) + "\n" + formatNumberedLine(b, lineText(b)));
150
207
  }
151
208
 
152
- return { ...replacement, index, end: index + replacement.oldText.length };
209
+ return { ...replacement, oldText, newText, index, end: index + oldText.length };
153
210
  });
154
211
 
155
212
  matches.sort((a, b) => a.index - b.index);
@@ -168,8 +225,57 @@ function applyReplacements(target, content, requestedEdits) {
168
225
  return { updated, matches };
169
226
  }
170
227
 
228
+ function lineStartIndex(content, line) {
229
+ let index = 0;
230
+
231
+ for (let current = 1; current < line; current++) {
232
+ const next = content.indexOf("\n", index);
233
+
234
+ if (next < 0) return content.length;
235
+ index = next + 1;
236
+ }
237
+
238
+ return Math.min(index, content.length);
239
+ }
240
+
241
+ function lineEndIndex(content, startIndex, lineCount) {
242
+ let index = startIndex;
243
+
244
+ for (let i = 0; i < lineCount; i++) {
245
+ const next = content.indexOf("\n", index);
246
+
247
+ if (next < 0) return content.length;
248
+ index = next + 1;
249
+ }
250
+
251
+ return index;
252
+ }
253
+
254
+ function lineTextRange(content, line) {
255
+ const start = lineStartIndex(content, line);
256
+
257
+ return { start, end: lineEndIndex(content, start, 1) };
258
+ }
259
+
260
+ function shiftDiffLines(diff, delta) {
261
+ if (!diff || delta === 0) return diff;
262
+
263
+ return {
264
+ ...diff,
265
+ lines: diff.lines.map(line => ({
266
+ ...line,
267
+ lineNum: line.lineNum + delta,
268
+ newLineNum: line.newLineNum === undefined ? undefined : line.newLineNum + delta,
269
+ })),
270
+ };
271
+ }
272
+
171
273
  function applyViewReplace(target, content, start, end, oldText, newText) {
172
- const current = sliceLines(content, start, end - start + 1);
274
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
275
+ throw new Error("edit requires a valid view range in " + target);
276
+ }
277
+
278
+ const current = sliceLinesRaw(content, start, end - start + 1);
173
279
 
174
280
  if (current !== oldText) {
175
281
  const shown = current.length ? current : content;
@@ -177,18 +283,104 @@ function applyViewReplace(target, content, start, end, oldText, newText) {
177
283
  throw new Error("edit view is stale in " + target + ": lines " + start + "-" + end + " changed\n" + numberedPreview(shown));
178
284
  }
179
285
 
180
- const hadTrail = content.endsWith("\n");
181
- const lines = content.split("\n");
286
+ const startIndex = lineStartIndex(content, start);
287
+ const endIndex = lineEndIndex(content, startIndex, Math.max(0, end - start + 1));
288
+ const hasSuffix = endIndex < content.length;
289
+ const separator = hasSuffix && content.slice(Math.max(0, endIndex - 2), endIndex) === "\r\n" ? "\r\n" : "\n";
290
+ const eofNewline = content.endsWith("\r\n") ? "\r\n" : "\n";
291
+ let insert = newText;
182
292
 
183
- if (hadTrail && lines.at(-1) === "") lines.pop();
184
- const insert = newText.split("\n");
185
-
186
- if (newText.endsWith("\n") && insert.at(-1) === "") insert.pop();
187
- const updated = [...lines.slice(0, start - 1), ...insert, ...lines.slice(end)].join("\n") + (hadTrail ? "\n" : "");
293
+ // A view replaces whole source lines. Preserve the separator before following
294
+ // lines, but let an explicit trailing newline change a no-trailing-newline EOF.
295
+ if (hasSuffix && insert !== "" && !insert.endsWith("\n")) insert += separator;
296
+ if (!hasSuffix && content.endsWith("\n") && insert !== "" && !insert.endsWith("\n")) insert += eofNewline;
297
+ const updated = content.slice(0, startIndex) + insert + content.slice(endIndex);
188
298
 
189
299
  return { updated, oldText, newText };
190
300
  }
191
301
 
302
+ const WRITE_DIFF_MAX_READ_BYTES = 512 * 1024;
303
+ const WRITE_APPEND_MAX_READ_BYTES = 64 * 1024 * 1024;
304
+ const QUICK_CHECK_MAX_CHARS = 2 * 1024 * 1024;
305
+
306
+ async function countContentLines(target, signal) {
307
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
308
+
309
+ try {
310
+ const stat = await file.stat();
311
+
312
+ if (!stat.isFile()) return null;
313
+ let newlines = 0;
314
+ let last = -1;
315
+ let total = 0;
316
+
317
+ for await (const chunk of file.createReadStream({ autoClose: false, signal })) {
318
+ for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) newlines++;
319
+ last = chunk.at(-1);
320
+ total += chunk.length;
321
+ }
322
+
323
+ return total === 0 ? 0 : newlines + (last === 10 ? 0 : 1);
324
+ } finally { await file.close(); }
325
+ }
326
+
327
+ function contentLineInfo(text, previewLimit = 0) {
328
+ if (text === "") return { count: 0, preview: [], newlines: 0 };
329
+ const preview = [];
330
+ let count = 0;
331
+ let start = 0;
332
+
333
+ while (start <= text.length) {
334
+ const newline = text.indexOf("\n", start);
335
+ const end = newline < 0 ? text.length : newline;
336
+
337
+ if (end === text.length && end === start && text.endsWith("\n")) break;
338
+ if (preview.length < previewLimit) preview.push(text.slice(start, end).replace(/\r$/, ""));
339
+ count++;
340
+ if (newline < 0) break;
341
+ start = newline + 1;
342
+ }
343
+
344
+ return { count, preview, newlines: count - Number(!text.endsWith("\n")) };
345
+ }
346
+
347
+ function boundedEditDiff(target, original, matches) {
348
+ const lines = [];
349
+ let shift = 0;
350
+ let added = 0;
351
+ let removed = 0;
352
+
353
+ for (const match of matches) {
354
+ const oldInfo = contentLineInfo(match.oldText, 32);
355
+ const newInfo = contentLineInfo(match.newText, 32);
356
+ const start = lineNumberAt(original, match.index);
357
+ const nextStart = start + shift;
358
+
359
+ removed += oldInfo.count;
360
+ added += newInfo.count;
361
+
362
+ for (let i = 0; i < oldInfo.preview.length; i++) lines.push({ type: "remove", lineNum: start + i, newLineNum: nextStart + i, text: oldInfo.preview[i] });
363
+ for (let i = 0; i < newInfo.preview.length; i++) lines.push({ type: "add", lineNum: nextStart + i, newLineNum: nextStart + i, text: newInfo.preview[i] });
364
+
365
+ shift += newInfo.newlines - oldInfo.newlines;
366
+ }
367
+
368
+ return { path: target, op: "edit", added, removed, lines };
369
+ }
370
+
371
+ function boundedWriteDiff(target, content, removed) {
372
+ const added = contentLineInfo(content, 64);
373
+
374
+ return {
375
+ path: target,
376
+ op: "write",
377
+ added: added.count,
378
+ removed: removed ?? 0,
379
+ displayLineCount: (removed ?? 0) + added.count,
380
+ lines: added.preview.map((text, i) => ({ type: "add", lineNum: i + 1, text })),
381
+ };
382
+ }
383
+
192
384
  function formatDirectoryEntry(name, type, size = 0) {
193
385
  const sizeSuffix = size ? `, ${size} bytes` : "";
194
386
 
@@ -233,12 +425,25 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
233
425
 
234
426
  if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
235
427
  signal?.throwIfAborted();
236
- const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path, query);
237
- const block = opened.content[0];
428
+ const target = resolvedPath ?? path.resolve(cwd, result.path);
429
+ let bounded = isString(query) && params.complete !== true;
430
+
431
+ if (bounded) {
432
+ const overlay = vfs.getOverlay(target);
238
433
 
239
- if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
240
- const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
434
+ if (overlay !== undefined) bounded = Buffer.byteLength(overlay, "utf8") > 512 * 1024;
435
+ else try { bounded = (await fs.stat(target)).size > 512 * 1024; } catch { bounded = false; }
436
+ }
241
437
 
438
+ const opened = await readFile(target, bounded
439
+ ? { ...params, about: undefined, offset: Math.max(1, result.line - 4), limit: params.limit ?? 120 }
440
+ : { ...params, about: undefined }, result.line, result.path, bounded ? undefined : query, signal);
441
+ const block = opened.content?.[0];
442
+
443
+ if (block?.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
444
+ const { firstLine, lastLine, sourceChars, nextOffset, complete, viewComplete } = opened.details;
445
+
446
+ if (lastLine < firstLine) return textResult(JSON.stringify({ status: "incomplete", path: result.path, line: result.line, signature: result.signature ?? "", confidence: result.confidence ?? 0, context: result.context ?? [], message: "offset is beyond the end of " + result.path }), { ...opened.details, isSnap: true });
242
447
  const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
243
448
  text: block.text.slice(0, sourceChars), complete, nextOffset };
244
449
 
@@ -249,8 +454,10 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
249
454
  async function readDirectory(dirPath, signal) {
250
455
  signal?.throwIfAborted();
251
456
  const rows = new Map();
457
+ let truncated = false;
252
458
 
253
459
  for (const file of vfs.getOverlayPaths()) {
460
+ if (rows.size >= MAX_DIRECTORY_ENTRIES) { truncated = true; break; }
254
461
  const relative = path.relative(dirPath, file);
255
462
 
256
463
  if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
@@ -267,16 +474,135 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
267
474
  entries = [];
268
475
  }
269
476
 
270
- for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
477
+ for (let i = 0; i < entries.length; i++) {
478
+ if ((i & 127) === 0) signal?.throwIfAborted();
479
+ if (rows.size >= MAX_DIRECTORY_ENTRIES) { truncated = true; break; }
480
+ if (!rows.has(entries[i].name)) rows.set(entries[i].name, await formatLsEntry(dirPath, entries[i]));
481
+ }
482
+
483
+ const values = [...rows.values()];
484
+ const text = values.join("\n") + (truncated ? "\n[directory listing truncated at " + MAX_DIRECTORY_ENTRIES + " entries]" : "");
485
+
486
+ return textResult(text, { path: dirPath, directory: true, count: rows.size, entries: values, outputTruncated: truncated });
487
+ }
488
+
489
+ /** Read an explicit line window without materializing the whole file when possible. */
490
+ async function readWindow(targetPath, startLine, lineCount, maxBytes, signal) {
491
+ const overlay = vfs.getOverlay(targetPath);
492
+
493
+ if (overlay !== undefined) {
494
+ const window = sliceLinesRawInfo(overlay, startLine, lineCount);
495
+ const satisfied = lineCount === undefined || lineCount === 0 || window.text === "" || window.count >= lineCount || window.eof;
496
+
497
+ return { text: window.text, satisfied, whole: window.whole };
498
+ }
499
+
500
+ let file;
501
+
502
+ try { file = await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
503
+ catch (error) {
504
+ if (error.code === "ENOENT") {
505
+ const missing = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question)");
506
+ missing.code = "ENOENT";
507
+ throw missing;
508
+ }
509
+
510
+ throw error;
511
+ }
512
+
513
+ try {
514
+ const stat = await file.stat();
515
+
516
+ if (!stat.isFile()) throw new Error("read requires a regular file: " + targetPath);
517
+ if (lineCount === 0) {
518
+ if (stat.size === 0) vfs.setCache(targetPath, "");
519
+
520
+ return { text: "", satisfied: true, whole: stat.size === 0 };
521
+ }
271
522
 
272
- return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
523
+ const wantedLines = lineCount === undefined ? Infinity : startLine + lineCount - 1;
524
+ const scan = Buffer.alloc(64 * 1024);
525
+ const parts = [];
526
+ let position = 0;
527
+ let linesSeen = 0;
528
+ let started = startLine === 1;
529
+ let startByte = started ? 0 : -1;
530
+ let done = false;
531
+ let doneByte = -1;
532
+ let collected = 0;
533
+
534
+ while (position < stat.size && !done && collected <= maxBytes) {
535
+ signal?.throwIfAborted();
536
+ const { bytesRead } = await file.read(scan, 0, scan.length, position);
537
+
538
+ if (bytesRead <= 0) break;
539
+ let begin = 0;
540
+
541
+ if (!started) {
542
+ while (begin < bytesRead && linesSeen < startLine - 1) if (scan[begin++] === 10) linesSeen++;
543
+ if (linesSeen < startLine - 1) {
544
+ position += bytesRead;
545
+ continue;
546
+ }
547
+ started = true;
548
+ startByte = position + begin;
549
+ }
550
+
551
+ let end = bytesRead;
552
+
553
+ if (wantedLines !== Infinity) {
554
+ for (let i = begin; i < bytesRead; i++) {
555
+ if (scan[i] !== 10) continue;
556
+ linesSeen++;
557
+ if (linesSeen === wantedLines) {
558
+ end = i + 1;
559
+ done = true;
560
+ doneByte = position + end;
561
+ break;
562
+ }
563
+ }
564
+ }
565
+
566
+ const takeBegin = position === startByte ? begin : Math.max(0, startByte - position);
567
+ const takeEnd = Math.min(end, takeBegin + Math.max(0, maxBytes + 1 - collected));
568
+
569
+ if (takeEnd > takeBegin) {
570
+ parts.push(Buffer.from(scan.subarray(takeBegin, takeEnd)));
571
+ collected += takeEnd - takeBegin;
572
+ }
573
+
574
+ position += bytesRead;
575
+ }
576
+
577
+ if (!started) {
578
+ if (stat.size === 0) vfs.setCache(targetPath, "");
579
+
580
+ return { text: "", satisfied: true, whole: stat.size === 0 };
581
+ }
582
+ const text = Buffer.concat(parts, collected).toString("utf8");
583
+ const satisfied = (done && startByte + collected >= doneByte) || startByte + collected >= stat.size;
584
+ const whole = startLine === 1 && startByte === 0 && startByte + collected >= stat.size;
585
+
586
+ // Even a bounded window owns a full byte snapshot. The observed stat
587
+ // ties signing to the file version that supplied these window bytes.
588
+ await vfs.recordExpected(targetPath, stat);
589
+ if (whole) vfs.setCache(targetPath, text);
590
+
591
+ return { text, satisfied, whole };
592
+ } finally {
593
+ await file.close();
594
+ }
273
595
  }
274
596
 
275
597
  async function readAdapter(params, signal) {
276
598
  signal?.throwIfAborted();
599
+ params = normalizeReadWindow(params);
600
+ if (!isObject(params)) throw new Error("read requires an options object");
277
601
  const cwd = getCwd();
278
602
  const targetParam = params?.path ?? params?.target;
279
603
 
604
+ if (params?.path !== undefined && params?.target !== undefined && params.path !== params.target) throw new Error("read accepts either path or target, not both");
605
+
280
606
  if (Array.isArray(targetParam)) {
281
607
  if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
282
608
 
@@ -284,9 +610,13 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
284
610
 
285
611
  const results = await Promise.all(targetParam.map(async p => {
286
612
  try {
287
- const block = (await readAdapter({ ...params, path: p }, signal)).content[0];
613
+ const res = await readAdapter({ ...params, path: p, target: undefined }, signal);
614
+ const block = res.content?.[0];
615
+ const item = block?.type === "image" ? block
616
+ : res.details?.directory === true && Array.isArray(res.details.entries) ? res.details.entries
617
+ : block?.text ?? "";
288
618
 
289
- return { text: block.type === "image" ? block : block.text };
619
+ return { text: item };
290
620
  } catch (error) {
291
621
  signal?.throwIfAborted();
292
622
 
@@ -348,14 +678,33 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
348
678
  async function readSingle(params, cwd, targetParam, signal) {
349
679
  params = sessionJsonArgs({ ...params, path: targetParam });
350
680
  validateJsonRead(params);
681
+ for (const [key, value] of [["resolve", params.resolve], ["complete", params.complete], ["outline", params.outline], ["evidence", params.evidence]]) {
682
+ if (value !== undefined && typeof value !== "boolean") throw new Error("read " + key + " must be a boolean");
683
+ }
684
+
685
+ if (params.about !== undefined && !isString(params.about)) throw new Error("read about must be a string");
686
+ if (params.query !== undefined && !isString(params.query)) throw new Error("read query must be a string");
687
+ const focusModes = [params.about !== undefined, params.query !== undefined, params.outline === true].filter(Boolean).length;
688
+
689
+ if (focusModes > 1 || (params.outline === true && params.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
690
+ if (params.resolve === true && params.complete === true) throw new Error("read accepts either resolve or complete, not both");
691
+ if ((focusModes === 1 || params.evidence === true) && params.complete === true) throw new Error("complete:true requires a raw file read, not a source view");
351
692
  targetParam = params.path;
352
693
 
353
694
  if (isString(targetParam) && /^(?:agent|artifact):\/\//i.test(targetParam)) {
695
+ if (focusModes || params.evidence === true) throw new Error("session resources do not support about/query/outline/evidence views");
354
696
  const target = await resolveSessionResource(targetParam, signal);
355
697
 
356
698
  return params.resolve
357
699
  ? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
358
- : readFile(target, params, undefined, targetParam);
700
+ : readFile(target, params, undefined, targetParam, undefined, signal);
701
+ }
702
+
703
+ if (params.evidence === true) {
704
+ const query = params.about ?? params.query ?? targetParam;
705
+ const scope = targetParam !== query || looksLikePath(targetParam) ? targetParam : undefined;
706
+
707
+ return adapters.evidence({ ...params, query, path: scope }, signal);
359
708
  }
360
709
 
361
710
  if (isString(params?.query)) {
@@ -364,47 +713,169 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
364
713
  return sourceRead(params.query, scope, signal, params);
365
714
  }
366
715
 
716
+ if (params.outline === true) {
717
+ const targetPath = resolveReadPath(cwd, targetParam);
718
+ const text = await vfs.read(targetPath, { maxBytes: 2 * 1024 * 1024 });
719
+ const outline = extractStructuralSurface(text, path.extname(targetPath));
720
+
721
+ return textResult(JSON.stringify(outline, null, 2), { path: targetPath, count: outline.items.length });
722
+ }
723
+
367
724
  const existing = await probeExistingPath(cwd, targetParam, vfs);
368
725
 
726
+ if (params.resolve === true && isString(params.about) && existing && !existing.directory) {
727
+ throw new Error("resolve:true cannot combine with about on a file; use about for a focused outline or resolve for source text");
728
+ }
729
+
369
730
  if (existing) {
370
- if (!existing.directory) return params.resolve
371
- ? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
372
- : readFile(existing.path, params);
731
+ if (!existing.directory) {
732
+ if (isString(params.about) && existing.size > 512 * 1024) {
733
+ if (existing.overlay !== undefined) return focusedOverlayText(existing.path, relativeSlash(cwd, existing.path), existing.overlay, params.about, signal);
734
+ return focusedLargeFile(existing.path, relativeSlash(cwd, existing.path), params.about, signal);
735
+ }
736
+
737
+ return params.resolve
738
+ ? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
739
+ : readFile(existing.path, params, undefined, undefined, undefined, signal);
740
+ }
373
741
 
374
742
  if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
375
743
 
376
744
  return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
377
745
  }
378
746
 
379
- if (params.json === undefined && !looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
747
+ if (params.json === undefined && !looksLikePath(targetParam)) {
748
+ const scope = isString(params.about) ? resolveReadPath(cwd, targetParam) : cwd;
749
+
750
+ return sourceRead(isString(params.about) ? params.about : targetParam, scope, signal, params);
751
+ }
752
+ if (params.resolve === true && params.complete !== true) {
753
+ return textResult(JSON.stringify({ status: "not_found", path: null, line: null, signature: "", confidence: 0, context: [] }), { isSnap: true });
754
+ }
380
755
  const targetPath = resolveReadPath(cwd, targetParam);
381
756
 
382
- return readFile(targetPath, params);
757
+ return readFile(targetPath, params, undefined, undefined, undefined, signal);
758
+ }
759
+
760
+ /** Focus a huge file through rg instead of materializing it just to fold declarations. */
761
+ async function focusedLargeFile(targetPath, rel, about, signal) {
762
+ const tokens = tokenizeQuery(about).tokens;
763
+ const stems = [...new Set(tokens.map(token => stem(token).slice(0, 128)))];
764
+
765
+ if (tokens.length > 16) throw new Error("about is too broad; use at most 16 keywords");
766
+ if (!stems.length) throw new Error("about needs at least one searchable keyword");
767
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256);
768
+ const args = ["rg", "--fixed-strings", "--ignore-case", "--line-number", "--before-context", "3", "--after-context", "3"];
769
+
770
+ for (const token of stems) args.push("-e", token);
771
+ args.push("--", targetPath);
772
+ const observed = await fs.stat(targetPath);
773
+ const res = await runCommand(args, { cwd: path.dirname(targetPath), timeoutMs: 15000, maxOutputChars: budget, signal });
774
+
775
+ if (res.exitCode === 0 || res.exitCode === 1) await vfs.recordExpected(targetPath, observed);
776
+ if (res.exitCode === 1) return textResult("// " + rel + " · no matching text\n", { path: targetPath, outputTruncated: false, complete: false });
777
+ if (res.exitCode !== 0) throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
778
+ const marker = res.outputTruncated ? "\n[focused read truncated; narrow about or use read(path, line, count)]" : "";
779
+
780
+ return textResult("// " + rel + " · focused text windows (not a complete file); read(path, line, count) for raw text\n" + res.stdout + marker,
781
+ { path: targetPath, outputTruncated: res.outputTruncated, complete: false });
782
+ }
783
+
784
+ /** Focus a large staged file without materializing another bounded window of its whole text. */
785
+ function focusedOverlayText(targetPath, rel, overlay, about, signal) {
786
+ const { tokens } = tokenizeQuery(about);
787
+
788
+ if (tokens.length > 16) throw new Error("about is too broad; use at most 16 keywords");
789
+ const stems = tokens.map(token => stem(token).slice(0, 128));
790
+ const hits = [];
791
+ let line = 1;
792
+ let start = 0;
793
+
794
+ while (start <= overlay.length) {
795
+ signal?.throwIfAborted();
796
+ const newline = overlay.indexOf("\n", start);
797
+ const end = newline < 0 ? overlay.length : newline + 1;
798
+ const row = overlay.slice(start, newline < 0 ? end : newline).replace(/\r$/, "");
799
+
800
+ if (stems.some(st => row.toLowerCase().includes(st))) {
801
+ hits.push(line);
802
+ if (hits.length >= 200) break;
803
+ }
804
+
805
+ if (end === overlay.length) break;
806
+ start = end;
807
+ line++;
808
+ }
809
+
810
+ const out = [];
811
+ let cursor = 1;
812
+ let used = 0;
813
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256);
814
+ let truncated = hits.length >= 200;
815
+
816
+ for (const hit of hits) {
817
+ const from = Math.max(cursor, hit - 3);
818
+ const to = hit + 3;
819
+ const first = lineStartIndex(overlay, from);
820
+ const last = lineTextRange(overlay, Math.min(to, line)).end;
821
+ const body = overlay.slice(first, last);
822
+
823
+ if (used + body.length > budget) { truncated = true; break; }
824
+ if (out.length && from > cursor) out.push("...");
825
+ out.push(`// ${rel}:${from}\n${body}`);
826
+ used += body.length;
827
+ cursor = Math.max(cursor, to + 1);
828
+ }
829
+
830
+ if (!out.length) return textResult("// " + rel + (hits.length
831
+ ? " · matching text exceeds view budget; first match at line " + hits[0] + "; use read(path, line, count)\n"
832
+ : " · no matching staged text\n"), { path: targetPath, outputTruncated: truncated, complete: false });
833
+
834
+ const marker = truncated ? "\n[focused read truncated; narrow about or use read(path, line, count)]" : "";
835
+
836
+ return textResult("// " + rel + " · focused staged text windows (not a complete file)\n" + out.join("\n") + marker, { path: targetPath, outputTruncated: truncated, complete: false });
383
837
  }
384
838
 
385
839
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
386
- async function readFile(targetPath, params, sourceLine, displayPath, query) {
840
+ async function readFile(targetPath, params, sourceLine, displayPath, query, signal) {
387
841
  const cwd = getCwd();
388
842
  const rel = displayPath ?? relativeSlash(cwd, targetPath);
389
843
 
844
+ if (isString(params?.about) && tokenizeQuery(params.about).tokens.length > 16) {
845
+ throw new Error("about is too broad; use at most 16 keywords");
846
+ }
847
+
390
848
  if (params.json !== undefined) {
391
849
  const project = jsonProjector(params.json);
392
- const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES });
850
+ const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES, label: "JSON input" });
393
851
  let document;
394
852
 
395
853
  try { document = JSON.parse(text); }
396
854
  catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
397
855
 
398
856
  const many = Array.isArray(params.json);
399
- let remaining = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256) - (many ? params.json.length + 1 : 0);
857
+ const selectors = many ? params.json.map(selector => String(selector)) : [params.json === true ? "." : String(params.json)];
858
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256) - (many ? params.json.length + 1 : 0);
859
+ let remaining = budget;
400
860
  const parts = [];
401
861
 
402
- for (const value of project(document)) {
403
- const encoded = JSON.stringify(value);
404
- remaining -= encoded.length;
862
+ try {
863
+ let index = 0;
864
+
865
+ for (const value of project(document)) {
866
+ const encoded = JSON.stringify(value);
405
867
 
406
- if (remaining < 0) throw new Error("JSON selection exceeds the read budget; select narrower fields or an array slice such as .items[0:10]");
407
- parts.push(encoded);
868
+ if (encoded.length > remaining) {
869
+ throw new Error("JSON selection exceeds the read budget for " + rel + " (" + (selectors[index] ?? "selector") + ": " + encoded.length + " chars, " + remaining + " remaining of " + budget + "); select narrower fields or an array slice such as .items[0:10]");
870
+ }
871
+
872
+ remaining -= encoded.length;
873
+ parts.push(encoded);
874
+ index++;
875
+ }
876
+ } catch (error) {
877
+ if (error instanceof Error && error.message.startsWith("JSON selection exceeds the read budget")) throw error;
878
+ throw new Error("JSON selection failed for " + rel + " (" + selectors.join(", ") + "): " + (error instanceof Error ? error.message : String(error)));
408
879
  }
409
880
 
410
881
  return textResult(many ? "[" + parts.join(",") + "]" : parts[0], { path: targetPath, json: true, complete: true });
@@ -413,14 +884,64 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
413
884
  const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
414
885
 
415
886
  if (mime) {
416
- if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
417
887
  const staged = vfs.getOverlay(targetPath);
418
- const bytes = staged === undefined ? await fs.readFile(targetPath) : Buffer.from(staged);
888
+ let bytes;
889
+
890
+ if (staged !== undefined) {
891
+ const size = Buffer.byteLength(staged, "utf8");
892
+
893
+ if (size > 20 * 1024 * 1024) throw new Error("image " + rel + " is " + size + " bytes (" + (size / 1024 / 1024).toFixed(1) + " MiB); the image read limit is 20971520 bytes (20 MiB); resize or select fewer/smaller images");
894
+ bytes = Buffer.from(staged);
895
+ } else {
896
+ let file;
897
+
898
+ try { file = await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
899
+ catch (error) {
900
+ if (error.code === "ENOENT") {
901
+ const missing = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question)");
902
+ missing.code = "ENOENT";
903
+ throw missing;
904
+ }
905
+
906
+ throw error;
907
+ }
908
+
909
+ try {
910
+ const stat = await file.stat();
911
+
912
+ if (!stat.isFile()) throw new Error("image read requires a regular file: " + targetPath);
913
+ if (stat.size > 20 * 1024 * 1024) throw new Error("image " + rel + " is " + stat.size + " bytes (" + (stat.size / 1024 / 1024).toFixed(1) + " MiB); the image read limit is 20971520 bytes (20 MiB); resize or select fewer/smaller images");
914
+ bytes = await file.readFile({ signal });
915
+ await vfs.recordExpected(targetPath, stat);
916
+ } finally { await file.close(); }
917
+ }
918
+
919
+ if (bytes.length > 20 * 1024 * 1024) throw new Error("image " + rel + " is " + bytes.length + " bytes (" + (bytes.length / 1024 / 1024).toFixed(1) + " MiB); the image read limit is 20971520 bytes (20 MiB); resize or select fewer/smaller images");
419
920
 
420
921
  return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
421
922
  }
422
923
 
423
- const text = await vfs.read(targetPath);
924
+ const explicit = isNumber(params?.offset) || isNumber(params?.limit);
925
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
926
+ let text;
927
+ let windowed = false;
928
+ let windowSatisfied = true;
929
+ let windowWhole = false;
930
+
931
+ const canWindow = params.complete !== true && !isString(params?.about) && !isString(query);
932
+
933
+ if (canWindow) {
934
+ const startLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
935
+ const lineCount = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : undefined;
936
+ const window = await readWindow(targetPath, startLine, lineCount, budget * 4 + 1024, signal);
937
+ text = window.text;
938
+ windowed = true;
939
+ windowSatisfied = window.satisfied;
940
+ windowWhole = window.whole === true;
941
+ } else {
942
+ text = await vfs.read(targetPath, { maxBytes: 64 * 1024 * 1024 });
943
+ }
944
+
424
945
  index.touch(rel);
425
946
 
426
947
  if (isString(params?.about)) {
@@ -434,30 +955,31 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
434
955
  }
435
956
  }
436
957
 
437
- const explicit = isNumber(params?.offset) || isNumber(params?.limit);
438
- const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
439
958
  let offset = params?.offset;
440
959
  let limit = params?.limit;
960
+ let viewComplete;
441
961
 
442
- if (!explicit && params.resolve) {
962
+ if (params.resolve && isString(query)) {
443
963
  const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(targetPath, text));
444
964
  const span = pickSpan(spans, { line: sourceLine, name: query });
445
965
 
446
966
  if (span) {
967
+ const spanLines = span.end - span.start + 1;
447
968
  offset = span.start;
448
- limit = span.end - span.start + 1;
969
+ limit = isNumber(params.limit) ? Math.min(params.limit, spanLines) : spanLines;
970
+ viewComplete = limit >= spanLines;
449
971
  }
450
972
  }
451
973
 
452
974
  offset ??= sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1;
453
975
  const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
454
- const sliced = sliceLines(text, offset, limit);
976
+ const sliced = windowed ? text : sliceLinesRaw(text, offset, limit);
455
977
 
456
978
  if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
457
979
  throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use json:".field" for JSON reports, about for text selection, edit() for replacements, or reconstruct resolve:true source windows`);
458
980
  }
459
981
 
460
- if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
982
+ if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget) || (windowed && !windowSatisfied)) {
461
983
  if (!explicit && !params.resolve && path.extname(targetPath).toLowerCase() === ".json") throw new Error("incomplete JSON read of " + rel + "; use the json selector option to parse the whole document before projection, or explicit offset/limit for raw text windows");
462
984
  let cap = budget - 160;
463
985
 
@@ -480,13 +1002,17 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
480
1002
  if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
481
1003
  const body = sliced.slice(0, end + 1);
482
1004
  const next = firstLine + body.split("\n").length - 1;
1005
+ ledger.recordOrigin(rel, firstLine, sourceLines(body), explicit);
483
1006
 
484
- return textResult(body + `\n[read truncated; continue with read({path:${JSON.stringify(rel)}, offset:${next}})]`, { path: targetPath, outputTruncated: true, nextOffset: next, firstLine, lastLine: next - 1, sourceChars: body.length, complete: false });
1007
+ return textResult(body + `\n[read truncated; continue with read({path:${JSON.stringify(rel)}, offset:${next}})]`, { path: targetPath, outputTruncated: true, nextOffset: next, firstLine, lastLine: next - 1, sourceChars: body.length, complete: false, viewComplete: false });
485
1008
  }
486
1009
 
487
- ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
1010
+ const slicedLines = sliced.length <= 512 * 1024 ? sourceLines(sliced) : null;
1011
+ const slicedLineCount = slicedLines?.length ?? contentLineInfo(sliced).count;
488
1012
 
489
- return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + sliced.split("\n").length - 1 - Number(sliced.endsWith("\n")), sourceChars: sliced.length, complete: sliced === text });
1013
+ if (slicedLines) ledger.recordOrigin(rel, firstLine, slicedLines, explicit);
1014
+
1015
+ return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + slicedLineCount - 1, sourceChars: sliced.length, complete: windowed ? windowWhole : sliced === text, viewComplete });
490
1016
  }
491
1017
 
492
1018
  /**
@@ -496,17 +1022,27 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
496
1022
  */
497
1023
  async function editSummary(cwd, target, original, updated, diff, signal, span) {
498
1024
  const rel = relativeSlash(cwd, target);
499
- const newLines = updated.split("\n");
1025
+ const smallUpdated = updated.length <= 512 * 1024;
1026
+ const newLines = smallUpdated ? updated.split("\n") : null;
1027
+ const lineCount = newLines ? newLines.length : contentLineInfo(updated).count;
1028
+ const lineAt = n => {
1029
+ if (newLines) return newLines[n - 1] ?? "";
1030
+ const { start, end } = lineTextRange(updated, n);
1031
+
1032
+ return updated.slice(start, end).replace(/\r?\n$/, "");
1033
+ };
500
1034
  const ranges = [];
501
1035
 
502
1036
  if (span && Number.isInteger(span.start) && Number.isInteger(span.end) && span.start >= 1 && span.end >= span.start) {
503
- ranges.push({ start: span.start, end: Math.min(newLines.length, span.end) });
1037
+ const end = Math.min(lineCount, span.end);
1038
+
1039
+ if (span.start <= end) ranges.push({ start: span.start, end });
504
1040
  } else {
505
1041
  const positions = diff.lines.filter(row => row.type !== "context")
506
- .map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
1042
+ .map(row => Math.min(lineCount, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
507
1043
 
508
1044
  for (const line of positions) {
509
- const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
1045
+ const start = Math.max(1, line - 2), end = Math.min(lineCount, line + 2);
510
1046
 
511
1047
  if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
512
1048
  else ranges.push({ start, end });
@@ -518,7 +1054,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
518
1054
 
519
1055
  for (const { start, end } of ranges) {
520
1056
  const last = Math.min(end, start + perRange - 1);
521
- const lines = newLines.slice(start - 1, last);
1057
+ const lines = Array.from({ length: Math.max(0, last - start + 1) }, (_, i) => lineAt(start + i));
522
1058
  ledger.recordOrigin(rel, start, lines);
523
1059
  blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
524
1060
 
@@ -526,7 +1062,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
526
1062
  }
527
1063
 
528
1064
  let out = blocks.join("\n");
529
- const check = quickCheck(updated, path.extname(target));
1065
+ const check = updated.length <= QUICK_CHECK_MAX_CHARS ? quickCheck(updated, path.extname(target)) : null;
530
1066
 
531
1067
  if (check && !check.ok) out += `\ncheck: ${check.message}`;
532
1068
  const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
@@ -538,18 +1074,26 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
538
1074
 
539
1075
  async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
540
1076
  // Diff rows carry the replaced fragments; declarations live on whole file lines.
541
- const oldLines = original.split("\n");
542
- const newLines = updated.split("\n");
1077
+ // Large-file edits skip owner-span mapping: it duplicates source bodies and can exhaust the guest heap.
1078
+ const canMapOwners = original.length <= 512 * 1024 && updated.length <= 512 * 1024;
1079
+ const oldLines = canMapOwners ? original.split("\n") : null;
1080
+ const newLines = canMapOwners ? updated.split("\n") : null;
1081
+ const changedLine = (text, lines, number) => {
1082
+ if (lines) return lines[number - 1] ?? "";
1083
+ const { start, end } = lineTextRange(text, number);
1084
+
1085
+ return text.slice(start, end).replace(/\r?\n$/, "");
1086
+ };
543
1087
  const names = new Set();
544
1088
  const spans = new Map();
545
1089
 
546
1090
  for (const l of diff.lines) {
547
1091
  if (l.type === "context") continue;
548
1092
  const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
549
- const name = declaredName((l.type === "remove" ? oldLines : newLines)[number - 1] ?? "");
1093
+ const name = declaredName(changedLine(l.type === "remove" ? original : updated, l.type === "remove" ? oldLines : newLines, number));
550
1094
 
551
1095
  if (name) names.add(name);
552
- else {
1096
+ else if (canMapOwners) {
553
1097
  if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
554
1098
  const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
555
1099
 
@@ -584,18 +1128,36 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
584
1128
  const SOURCE_REF = /((?:\/|[A-Za-z]:[\\/])?(?:[\w.@-]+[\\/])*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
585
1129
 
586
1130
  /** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
587
- async function sourceWindow(cwd, commandCwd, file, lineNo) {
1131
+ async function sourceWindow(cwd, commandCwd, file, lineNo, signal) {
588
1132
  const candidate = path.resolve(commandCwd, file);
589
1133
  let text, rel;
590
1134
 
591
1135
  try {
592
1136
  const root = await fs.realpath(cwd);
1137
+ const cwdPrefix = path.resolve(cwd).endsWith(path.sep) ? path.resolve(cwd) : path.resolve(cwd) + path.sep;
1138
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
593
1139
 
594
- if (!candidate.startsWith(path.resolve(cwd) + path.sep) && !candidate.startsWith(root + path.sep)) return null;
1140
+ if (!candidate.startsWith(cwdPrefix) && !candidate.startsWith(rootPrefix)) return null;
595
1141
  const real = await fs.realpath(candidate);
1142
+ const handle = await fs.open(real, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
596
1143
 
597
- if (!real.startsWith(root + path.sep) || (await fs.stat(real)).size > 1024 * 1024) return null;
598
- text = await fs.readFile(real, "utf8");
1144
+ try {
1145
+ const stat = await handle.stat();
1146
+
1147
+ if (!real.startsWith(rootPrefix) || !stat.isFile() || stat.size > 1024 * 1024) return null;
1148
+ const buffer = Buffer.alloc(Math.min(stat.size, 1024 * 1024));
1149
+ let offset = 0;
1150
+
1151
+ while (offset < buffer.length) {
1152
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
1153
+
1154
+ if (bytesRead <= 0) break;
1155
+ offset += bytesRead;
1156
+ signal?.throwIfAborted();
1157
+ }
1158
+
1159
+ text = buffer.subarray(0, offset).toString("utf8");
1160
+ } finally { await handle.close(); }
599
1161
  rel = relativeSlash(root, real);
600
1162
  } catch { return null; }
601
1163
 
@@ -612,7 +1174,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
612
1174
  }
613
1175
 
614
1176
  /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
615
- async function sourceForReferences(cwd, commandCwd, output) {
1177
+ async function sourceForReferences(cwd, commandCwd, output, signal) {
616
1178
  const seen = new Set();
617
1179
  const blocks = [];
618
1180
 
@@ -623,7 +1185,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
623
1185
 
624
1186
  if (seen.size >= 4) break;
625
1187
  seen.add(key);
626
- const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
1188
+ const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]), signal);
627
1189
 
628
1190
  if (block) blocks.push(block);
629
1191
  }
@@ -634,7 +1196,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
634
1196
  function outlineOptions(params, references) {
635
1197
  const options = { references };
636
1198
 
637
- if (params?.maxChars) options.maxChars = params.maxChars;
1199
+ if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
638
1200
 
639
1201
  return options;
640
1202
  }
@@ -650,7 +1212,10 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
650
1212
 
651
1213
  /** Where else a name appears (declaration line excluded), for outlines and edit results. */
652
1214
  async function referenceFinder(cwd, targetPath) {
653
- const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
1215
+ let files;
1216
+
1217
+ try { files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])]; }
1218
+ catch { return () => []; }
654
1219
 
655
1220
  if (!index.canScan(files)) return () => [];
656
1221
 
@@ -668,7 +1233,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
668
1233
 
669
1234
  hooks.summarizeEdit = editSummary;
670
1235
 
671
- return {
1236
+ const adapters = {
672
1237
  read: readAdapter,
673
1238
  async write(params, signal) {
674
1239
  const cwd = getCwd();
@@ -679,24 +1244,51 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
679
1244
  if (!isString(params?.content)) throw new Error("write requires string content");
680
1245
 
681
1246
  if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
682
- let content = params.content;
1247
+ if (params.allowReadArtifacts !== undefined && typeof params.allowReadArtifacts !== "boolean") throw new Error("write allowReadArtifacts must be a boolean");
1248
+ let content = String(params.content);
683
1249
 
684
- if (params.allowReadArtifacts !== true && /\[read truncated;|…\[(?:host-result|output|value) truncated \d+ chars\]…/u.test(content)) {
1250
+ if (params.allowReadArtifacts !== true && /\[read truncated;|…\[[^\]\n]*truncated[^\]\n]*\]…/u.test(content)) {
685
1251
  throw new Error("refusing to write truncated read output; use edit() or reconstruct complete source windows. Set allowReadArtifacts:true only to intentionally write literal truncation-marker text");
686
1252
  }
687
1253
 
688
1254
  let prevText = "";
1255
+ let removedLines;
1256
+ let stat;
1257
+
1258
+ try { stat = await fs.stat(target); }
1259
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1260
+
1261
+ const overlay = vfs.getOverlay(target);
1262
+ const existingBytes = overlay !== undefined ? Buffer.byteLength(overlay, "utf8") : stat?.size;
1263
+
1264
+ if (params.append === true) {
1265
+ if (existingBytes > WRITE_APPEND_MAX_READ_BYTES) throw new Error("append input exceeds " + WRITE_APPEND_MAX_READ_BYTES + " bytes; stream it with bash redirection instead");
1266
+
1267
+ try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
1268
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1269
+ content = prevText + content;
1270
+ } else if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
1271
+ if (overlay === undefined) {
1272
+ // Keep the CAS baseline without materializing a huge old body just to draw a receipt.
1273
+ await vfs.captureExpected(target);
1274
+
1275
+ try { removedLines = await countContentLines(target, signal); }
1276
+ catch (error) { if (error.code !== "ENOENT") throw error; else removedLines = 0; }
1277
+ } else {
1278
+ removedLines = contentLineInfo(overlay).count;
1279
+ }
1280
+ } else {
1281
+ try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true }); }
1282
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1283
+ }
689
1284
 
690
- try {
691
- prevText = await vfs.read(target, { preserveRead: true });
692
- } catch (error) { if (error.code !== "ENOENT") throw error; }
693
-
694
- if (params.append === true) content = prevText + content;
695
1285
  const { speculative } = await vfs.write(target, content);
696
1286
  index.touch(relativeSlash(cwd, target));
697
- const diff = buildWriteDiff(target, prevText, content);
1287
+ const diff = removedLines === undefined && content.length <= WRITE_DIFF_MAX_READ_BYTES
1288
+ ? buildWriteDiff(target, prevText, content)
1289
+ : boundedWriteDiff(target, content, removedLines ?? contentLineInfo(prevText).count);
698
1290
  const tag = speculative ? " (speculative)" : "";
699
- const check = quickCheck(content, path.extname(target));
1291
+ const check = content.length <= QUICK_CHECK_MAX_CHARS ? quickCheck(content, path.extname(target)) : null;
700
1292
  const warning = check && !check.ok ? "\ncheck: " + check.message : "";
701
1293
 
702
1294
  return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
@@ -707,18 +1299,21 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
707
1299
 
708
1300
  if (signal?.aborted) throw new Error("aborted");
709
1301
 
710
- const content = await vfs.read(target);
1302
+ const content = await vfs.read(target, { maxBytes: 64 * 1024 * 1024 });
711
1303
 
712
1304
  if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
1305
+ const viewText = String(params.viewText);
1306
+ const nextText = String(params.newText);
713
1307
  const windowNext = isString(params.oldText)
714
- ? applyReplacements(target, params.viewText, [{ oldText: params.oldText, newText: params.newText }]).updated
715
- : params.newText;
716
- const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, params.viewText, windowNext);
1308
+ ? applyReplacements(target, viewText, [{ oldText: String(params.oldText), newText: nextText }]).updated
1309
+ : nextText;
1310
+ const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, viewText, windowNext);
717
1311
  const { speculative } = await vfs.write(target, updated);
718
1312
  index.touch(relativeSlash(cwd, target));
719
- const diffFrom = isString(params.oldText) ? params.oldText : params.viewText;
720
- const diffTo = isString(params.oldText) ? params.newText : windowNext;
721
- const diff = buildEditDiff(target, content, diffFrom, diffTo);
1313
+ const diffFrom = isString(params.oldText) ? String(params.oldText) : viewText;
1314
+ const diffTo = isString(params.oldText) ? nextText : windowNext;
1315
+ const localDiff = buildEditDiff(target, viewText, diffFrom, diffTo);
1316
+ const diff = shiftDiffLines(localDiff, params.viewStart - 1);
722
1317
  const inserted = sourceLines(windowNext);
723
1318
  const spanEnd = params.viewStart + Math.max(inserted.length, 1) - 1;
724
1319
  const summary = await editSummary(cwd, target, content, updated, diff, signal, { start: params.viewStart, end: spanEnd });
@@ -733,8 +1328,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
733
1328
  const { speculative } = await vfs.write(target, updated);
734
1329
  index.touch(relativeSlash(cwd, target));
735
1330
 
736
- const diff =
737
- matches.length === 1
1331
+ const diff = content.length > 512 * 1024 || updated.length > 512 * 1024
1332
+ ? boundedEditDiff(target, content, matches)
1333
+ : matches.length === 1
738
1334
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
739
1335
  : buildMultiEditDiff(target, content, matches);
740
1336
 
@@ -747,9 +1343,11 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
747
1343
  let inputPath = params?.path;
748
1344
 
749
1345
  if (!inputPath && isString(params?.patch)) {
750
- const headerMatch = /^\+\+\+\s+[ab]\/(.+)$/m.exec(params.patch) || /^---\s+[ab]\/(.+)$/m.exec(params.patch);
1346
+ for (const match of params.patch.matchAll(/^(?:---|\+\+\+)\s+([^\t\n]+)/gm)) {
1347
+ const candidate = match[1].trim().replace(/^[ab]\//, "");
751
1348
 
752
- if (headerMatch) inputPath = headerMatch[1].trim();
1349
+ if (candidate !== "/dev/null") { inputPath = candidate; break; }
1350
+ }
753
1351
  }
754
1352
 
755
1353
  const target = await resolveWorkspacePath(cwd, inputPath, "apply_patch", false);
@@ -760,7 +1358,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
760
1358
 
761
1359
  if (signal?.aborted) throw new Error("aborted");
762
1360
 
763
- const original = await vfs.read(target);
1361
+ let original;
1362
+
1363
+ try {
1364
+ original = await vfs.read(target, { maxBytes: 64 * 1024 * 1024, preserveRead: true });
1365
+ } catch (error) {
1366
+ if (error?.code !== "ENOENT") throw error;
1367
+ original = "";
1368
+ }
1369
+ if (original.length > 2 * 1024 * 1024) throw new Error("apply_patch input exceeds 2 MiB; use edit() for targeted replacements");
764
1370
  const { resultText, hunkCount } = applyPatchToText(original, params.patch);
765
1371
  const { speculative } = await vfs.write(target, resultText);
766
1372
  const diff = buildPatchDiff(target, params.patch);
@@ -805,14 +1411,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
805
1411
  const cwd = getCwd();
806
1412
 
807
1413
  if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
1414
+ if (tokenizeQuery(params.query).tokens.length > 16) throw new Error("evidence query is too broad; use at most 16 keywords");
808
1415
 
809
1416
  if (signal?.aborted) throw new Error("aborted");
810
1417
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
811
1418
  const options = {};
812
1419
 
813
- if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
1420
+ if (Number.isInteger(params?.k) && params.k > 0) options.k = Math.min(params.k, 20);
814
1421
 
815
- if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
1422
+ if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
816
1423
  const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
817
1424
 
818
1425
  for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
@@ -824,7 +1431,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
824
1431
  const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
825
1432
 
826
1433
  if (signal?.aborted) throw new Error("aborted");
827
- const text = await vfs.read(target);
1434
+ const text = await vfs.read(target, { maxBytes: 2 * 1024 * 1024 });
828
1435
  const ext = path.extname(target);
829
1436
  const outline = extractStructuralSurface(text, ext);
830
1437
 
@@ -834,6 +1441,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
834
1441
  const cwd = getCwd();
835
1442
  const literal = Array.isArray(params?.args) && process.platform !== "win32" && params.args.length === Object.keys(params.args).length && params.args.every(isString);
836
1443
 
1444
+ if (params?.command !== undefined && !isString(params.command)) throw new Error("bash command must be a string");
837
1445
  if (literal && (!isString(params.command) || !Array.isArray(params.args) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
838
1446
  const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
839
1447
 
@@ -856,7 +1464,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
856
1464
  maxOutputChars: config.maxCallResultChars,
857
1465
  });
858
1466
  } catch (error) {
859
- if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message);
1467
+ if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message, signal);
860
1468
  throw error;
861
1469
  } finally {
862
1470
  vfs.invalidateCache();
@@ -868,7 +1476,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
868
1476
  const { stdout, stderr } = res;
869
1477
  let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
870
1478
 
871
- if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
1479
+ if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text, signal);
872
1480
 
873
1481
  return {
874
1482
  content: [{ type: "text", text }],
@@ -882,7 +1490,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
882
1490
 
883
1491
  if (!pattern) throw new Error("grep requires pattern");
884
1492
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
885
- const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
1493
+ const indexed = await grepIndexed(index, pattern, params, searchPath, cwd, file => vfs.getOverlay(file), vfs.getOverlayPaths());
886
1494
 
887
1495
  if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
888
1496
  // Large tree: real rg keeps its own output format.
@@ -899,30 +1507,14 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
899
1507
  const pattern = String(params?.pattern || "");
900
1508
 
901
1509
  if (!pattern) throw new Error("glob requires pattern");
902
- const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
1510
+ const fuzzy = await fuzzyFind(index, cwd, cwd, pattern, 20, vfs.getOverlayPaths());
903
1511
 
904
1512
  if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
905
- const indexed = await listIndexed(index, cwd, cwd, pattern);
1513
+ const indexed = await listIndexed(index, cwd, cwd, pattern, vfs.getOverlayPaths());
906
1514
 
907
1515
  if (indexed !== null) return textResult(indexed, { via: "index" });
908
1516
 
909
- const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
910
- () => null,
911
- );
912
-
913
- if (rg && (rg.exitCode === 0 || rg.exitCode === 1)) {
914
- return textResult(rg.stdout, { via: "rg" });
915
- }
916
-
917
- const findPattern = pattern.startsWith("./") ? pattern : `./${pattern}`;
918
-
919
- const fallback = await runCommand(["find", ".", "-type", "f", "-path", findPattern], {
920
- cwd,
921
- timeoutMs: 30_000,
922
- signal,
923
- });
924
-
925
- return textResult(fallback.stdout, { via: "find" });
1517
+ return listWithTools(cwd, pattern, cwd, signal, vfs.getOverlayPaths());
926
1518
  },
927
1519
  async find(params, signal) {
928
1520
  const cwd = getCwd();
@@ -931,22 +1523,38 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
931
1523
 
932
1524
  if (signal?.aborted) throw new Error("aborted");
933
1525
  const globPattern = pattern ? String(pattern) : null;
934
- const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
1526
+ const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern, 20, vfs.getOverlayPaths());
935
1527
 
936
1528
  if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
937
- const indexed = await listIndexed(index, searchDir, cwd, globPattern);
1529
+ const indexed = await listIndexed(index, searchDir, cwd, globPattern, vfs.getOverlayPaths());
938
1530
 
939
1531
  if (indexed !== null) return textResult(indexed, { via: "index" });
940
1532
 
941
- return listWithTools(searchDir, globPattern, cwd, signal);
1533
+ return listWithTools(searchDir, globPattern, cwd, signal, vfs.getOverlayPaths());
942
1534
  },
943
1535
  async ls(params, signal) {
944
1536
  const cwd = getCwd();
945
1537
  const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
1538
+ const pending = vfs.getOverlay(dirPath);
1539
+
1540
+ if (pending !== undefined) {
1541
+ const entry = formatDirectoryEntry(path.basename(dirPath), "file", Buffer.byteLength(pending, "utf8"));
1542
+
1543
+ return textResult(entry, { path: dirPath, directory: false, count: 1, entries: [entry] });
1544
+ }
1545
+ const stat = await fs.stat(dirPath).catch(() => null);
1546
+
1547
+ if (stat?.isFile()) {
1548
+ const entry = formatDirectoryEntry(path.basename(dirPath), "file", stat.size);
1549
+
1550
+ return textResult(entry, { path: dirPath, directory: false, count: 1, entries: [entry] });
1551
+ }
946
1552
 
947
1553
  return readDirectory(dirPath, signal);
948
1554
  },
949
1555
  };
1556
+
1557
+ return adapters;
950
1558
  }
951
1559
 
952
1560
  /**
@@ -1036,13 +1644,22 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1036
1644
  const sessionId = ctx?.sessionManager?.getSessionId?.();
1037
1645
  boundSessionId = sessionId;
1038
1646
  const registry = pi?.pi?.AgentRegistry?.global?.();
1039
- hostSession = sessionId && registry?.list
1040
- ? registry.list().map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
1647
+ let sessions = [];
1648
+
1649
+ try { sessions = registry?.list?.() ?? []; } catch {}
1650
+ if (!Array.isArray(sessions)) sessions = [];
1651
+ hostSession = sessionId
1652
+ ? sessions.map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
1041
1653
  : null;
1042
1654
  activeSignal = signal;
1043
1655
  vfs.signal = signal;
1044
1656
  }
1045
1657
 
1658
+ function evalToolNames() {
1659
+ try { return hostSession?.getEvalBridgeToolNames?.() ?? []; }
1660
+ catch { return []; }
1661
+ }
1662
+
1046
1663
  function hostTool(name) {
1047
1664
  if (!hostSession) return undefined;
1048
1665
  const metadata = definitions.get(name);
@@ -1050,13 +1667,14 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1050
1667
  // Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
1051
1668
  if (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
1052
1669
 
1053
- return hostSession.getToolForEvalBridge?.(name);
1670
+ try { return hostSession.getToolForEvalBridge?.(name); }
1671
+ catch { return undefined; }
1054
1672
  }
1055
1673
 
1056
1674
  function isCallable(name) {
1057
1675
  if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
1058
1676
 
1059
- if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
1677
+ if (hostSession && (hostSession.isDisposed || hostSession.sessionManager?.getSessionId?.() !== boundSessionId)) return false;
1060
1678
 
1061
1679
  // An internal adapter belongs to Supernova, not the host's visible tool list.
1062
1680
  const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
@@ -1065,18 +1683,27 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1065
1683
  if (nativeOwned) return true;
1066
1684
 
1067
1685
  if (hostSession) {
1068
- if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
1686
+ const evalNames = evalToolNames();
1687
+
1688
+ if (!evalNames.includes(name) && definitions.has(name)) return false;
1069
1689
 
1070
1690
  return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
1071
1691
  }
1072
1692
 
1073
- if (definitions.has(name) && isFunction(pi?.getActiveTools) && !pi.getActiveTools().includes(name)) return false;
1693
+ let activeTools;
1694
+
1695
+ try { activeTools = isFunction(pi?.getActiveTools) ? pi.getActiveTools() : undefined; } catch {}
1696
+
1697
+ if (definitions.has(name) && Array.isArray(activeTools) && !activeTools.includes(name)) return false;
1074
1698
 
1075
1699
  return executors.has(name) || Object.hasOwn(natives, name);
1076
1700
  }
1077
1701
 
1078
1702
  function refreshTools() {
1079
- const tools = pi?.getAllTools?.() ?? [];
1703
+ let listed = [];
1704
+
1705
+ try { listed = pi?.getAllTools?.() ?? []; } catch {}
1706
+ const tools = Array.isArray(listed) ? listed : [];
1080
1707
 
1081
1708
  for (const tool of tools) {
1082
1709
  if (!isString(tool?.name)) continue;
@@ -1163,14 +1790,33 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1163
1790
  if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
1164
1791
  const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
1165
1792
  let previous = "";
1793
+ let removedLines;
1794
+ let stat;
1166
1795
 
1167
- try {
1168
- previous = await vfs.read(target);
1169
- } catch (error) {
1170
- if (error?.code !== "ENOENT") throw error;
1796
+ try { stat = await fs.stat(target); }
1797
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1798
+
1799
+ const overlay = vfs.getOverlay(target);
1800
+ const existingBytes = overlay !== undefined ? Buffer.byteLength(overlay, "utf8") : stat?.size;
1801
+
1802
+ if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
1803
+ if (overlay === undefined) {
1804
+ await vfs.captureExpected(target);
1805
+
1806
+ try { removedLines = await countContentLines(target, activeSignal); }
1807
+ catch (error) { if (error.code !== "ENOENT") throw error; else removedLines = 0; }
1808
+ } else {
1809
+ removedLines = contentLineInfo(overlay).count;
1810
+ }
1811
+ } else {
1812
+ try {
1813
+ previous = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true });
1814
+ } catch (error) {
1815
+ if (error?.code !== "ENOENT") throw error;
1816
+ }
1171
1817
  }
1172
1818
 
1173
- return buildWriteDiff(target, previous, args.content);
1819
+ return removedLines === undefined ? buildWriteDiff(target, previous, args.content) : boundedWriteDiff(target, args.content, removedLines);
1174
1820
  }
1175
1821
 
1176
1822
  function completeRecord(record, res, fallbackDiff) {
@@ -1181,6 +1827,24 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1181
1827
  notifyCall(record);
1182
1828
  }
1183
1829
 
1830
+ function traceArgs(args) {
1831
+ if (!isObject(args)) return {};
1832
+ const out = {};
1833
+
1834
+ for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "op"]) {
1835
+ const value = args[key];
1836
+
1837
+ if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
1838
+ else if (Array.isArray(value)) out[key] = value.slice(0, 128).map(item => isString(item) ? truncateChars(item, 240, "trace").text : typeof item);
1839
+ }
1840
+
1841
+ if (isString(args.content)) out.content = args.content.length + " chars";
1842
+ if (Array.isArray(args.edits)) out.edits = args.edits.length + " edits";
1843
+ if (Array.isArray(args.args)) out.args = args.args.length + " argv";
1844
+
1845
+ return out;
1846
+ }
1847
+
1184
1848
  async function invokeRaw(name, args) {
1185
1849
  checkCallBudget(name);
1186
1850
  const callId = ++sharedRegistry.callSeq;
@@ -1189,7 +1853,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1189
1853
  if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
1190
1854
 
1191
1855
  const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
1192
- const record = { name: command, adapter: name, args: args || {}, time: Date.now() };
1856
+ const record = { name: command, adapter: name, args: traceArgs(args), time: Date.now() };
1193
1857
  trace.push(record);
1194
1858
  notifyCall(record);
1195
1859
 
@@ -1197,7 +1861,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1197
1861
  const delegated = hostTool(name);
1198
1862
  const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
1199
1863
  const argvOwned = name === "bash" && Array.isArray(args?.args) && process.platform !== "win32" && args.args.length === Object.keys(args.args).length && args.args.every(isString);
1200
-
1864
+ // Explicit overrides own all reads. Options, even false-valued ones,
1865
+ // must not silently bypass the host executor.
1201
1866
  if (exec && !argvOwned) {
1202
1867
  if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
1203
1868
 
@@ -1213,7 +1878,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1213
1878
 
1214
1879
  try {
1215
1880
  const res = await exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, delegated
1216
- ? { ...activeCtx, settings: hostSession.settings, toolNames: hostSession.getEvalBridgeToolNames(), autoApprove: false }
1881
+ ? { ...activeCtx, settings: hostSession.settings, toolNames: evalToolNames(), autoApprove: false }
1217
1882
  : activeCtx);
1218
1883
 
1219
1884
  completeRecord(record, res, fallbackDiff);
@@ -1227,7 +1892,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1227
1892
  const native = Object.hasOwn(natives, name) ? natives[name] : undefined;
1228
1893
 
1229
1894
  if (native) {
1230
- const res = await native(args || {}, activeSignal);
1895
+ const res = await native(argvOwned ? { ...(args || {}), args: args.args.map(String) } : args || {}, activeSignal);
1231
1896
  completeRecord(record, res);
1232
1897
 
1233
1898
  return res;
@@ -1253,7 +1918,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1253
1918
  ? res.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n")
1254
1919
  : undefined;
1255
1920
 
1256
- if (text) record.resultText = text;
1921
+ if (text) record.resultText = truncateChars(text, 4096, "trace").text;
1257
1922
  }
1258
1923
 
1259
1924
  async function call(name, args) {