pi-supernova 0.3.2 → 0.4.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.
@@ -16,6 +16,7 @@ import { SeenLedger } from "../context/ledger.js";
16
16
  import { quickCheck } from "../fs/check.js";
17
17
  import { declaredName } from "../context/repo-index.js";
18
18
  import { CausalVfs } from "../fs/vfs.js";
19
+ import { MAX_JSON_BYTES, jsonProjector, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
19
20
  import { applyPatchToText } from "../fs/patch.js";
20
21
  import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
21
22
  import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
@@ -31,10 +32,14 @@ function textResult(text, details) {
31
32
  function unwrapIfFullyQuoted(s) {
32
33
  if (s.length < 2) return s;
33
34
  const q = s[0];
35
+
34
36
  if (q !== "'" && q !== '"') return s;
37
+
35
38
  if (s[s.length - 1] !== q) return s;
36
39
  const inner = s.slice(1, -1);
40
+
37
41
  if (inner.includes(q)) return s;
42
+
38
43
  return inner;
39
44
  }
40
45
 
@@ -43,6 +48,7 @@ function sliceLines(text, offset, limit) {
43
48
  const lines = text.split("\n");
44
49
  const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
45
50
  const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
51
+
46
52
  return lines.slice(startIndex, startIndex + count).join("\n");
47
53
  }
48
54
 
@@ -59,52 +65,69 @@ function looksLikePath(target) {
59
65
  function resolveReadPath(cwd, target) {
60
66
  if (!isString(target) || !target.trim()) throw new Error("read requires path");
61
67
  const input = target.trim();
68
+
62
69
  return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
63
70
  }
64
71
 
65
72
  async function probeExistingPath(cwd, targetParam, vfs) {
66
73
  const targetPath = resolveReadPath(cwd, targetParam);
74
+
67
75
  if (vfs.getOverlay(targetPath) !== undefined) return { path: targetPath, directory: false };
76
+
68
77
  try {
69
78
  const st = await fs.stat(targetPath);
79
+
70
80
  return { path: targetPath, directory: st.isDirectory() };
71
81
  } catch (err) {
72
82
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
83
+
73
84
  if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
85
+
74
86
  return null;
75
87
  }
76
88
  }
77
89
 
78
90
  function applyReplacements(target, content, requestedEdits) {
79
91
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
92
+
80
93
  const matches = requestedEdits.map((replacement) => {
81
94
  if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
82
95
  throw new Error("edit requires non-empty oldText");
83
96
  }
97
+
84
98
  if (!isString(replacement?.newText)) throw new Error("edit requires newText");
85
99
  const index = content.indexOf(replacement.oldText);
100
+
86
101
  if (index < 0) {
87
102
  throw new Error(`edit target not found in ${target}: oldText must match the file byte-for-byte (read() it first; check whitespace and quotes)`);
88
103
  }
104
+
89
105
  if (content.indexOf(replacement.oldText, index + 1) >= 0) {
90
106
  throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
91
107
  }
108
+
92
109
  return { ...replacement, index, end: index + replacement.oldText.length };
93
110
  });
111
+
94
112
  matches.sort((a, b) => a.index - b.index);
113
+
95
114
  for (let i = 1; i < matches.length; i++) {
96
115
  if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
97
116
  }
117
+
98
118
  let updated = content;
119
+
99
120
  for (let i = matches.length - 1; i >= 0; i--) {
100
121
  const match = matches[i];
101
122
  updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
102
123
  }
124
+
103
125
  return { updated, matches };
104
126
  }
105
127
 
106
128
  function formatDirectoryEntry(name, type, size = 0) {
107
129
  const sizeSuffix = size ? `, ${size} bytes` : "";
130
+
108
131
  return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
109
132
  }
110
133
 
@@ -113,37 +136,47 @@ async function formatLsEntry(dirPath, entry) {
113
136
  const isSym = entry.isSymbolicLink();
114
137
  const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
115
138
  let size = 0;
139
+
116
140
  try {
117
141
  if (!isDir && !isSym) {
118
142
  const st = await fs.stat(path.join(dirPath, entry.name));
119
143
  size = st.size;
120
144
  }
121
145
  } catch {}
146
+
122
147
  return formatDirectoryEntry(entry.name, typeLabel, size);
123
148
  }
124
149
 
125
150
  function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
126
151
  const reads = createNativeScheduler();
152
+
127
153
  async function sourceRead(query, searchDir, signal, params = {}) {
128
154
  const cwd = getCwd();
155
+
129
156
  const includeHidden = path.relative(cwd, searchDir).split(path.sep)
130
157
  .some(segment => segment.startsWith(".") && segment.length > 1);
158
+
131
159
  const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
132
160
  pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
133
161
  overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
162
+
134
163
  return openSource(result, params, signal);
135
164
  }
136
165
 
137
166
  async function openSource(result, params, signal, resolvedPath) {
138
167
  const cwd = getCwd();
168
+
139
169
  if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
140
170
  signal?.throwIfAborted();
141
171
  const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path);
142
172
  const block = opened.content[0];
173
+
143
174
  if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
144
175
  const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
176
+
145
177
  const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
146
178
  text: block.text.slice(0, sourceChars), complete, nextOffset };
179
+
147
180
  return textResult(params.resolve ? JSON.stringify(source) : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
148
181
  { ...opened.details, isSnap: true });
149
182
  }
@@ -151,20 +184,26 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
151
184
  async function readDirectory(dirPath, signal) {
152
185
  signal?.throwIfAborted();
153
186
  const rows = new Map();
187
+
154
188
  for (const file of vfs.getOverlayPaths()) {
155
189
  const relative = path.relative(dirPath, file);
190
+
156
191
  if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
157
192
  const [name, child] = relative.split(path.sep);
158
193
  rows.set(name, child === undefined
159
194
  ? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
160
195
  : formatDirectoryEntry(name, "dir"));
161
196
  }
197
+
162
198
  let entries;
199
+
163
200
  try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
164
201
  if (error.code !== "ENOENT" || rows.size === 0) throw error;
165
202
  entries = [];
166
203
  }
204
+
167
205
  for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
206
+
168
207
  return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
169
208
  }
170
209
 
@@ -172,75 +211,109 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
172
211
  signal?.throwIfAborted();
173
212
  const cwd = getCwd();
174
213
  const targetParam = params?.path ?? params?.target;
214
+
175
215
  if (Array.isArray(targetParam)) {
176
- if (targetParam.some(p => !isString(p) || !p.trim())) throw new Error("read paths must be non-empty strings");
177
216
  if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
217
+
218
+ for (const p of targetParam) if (!isString(p) || !p.trim()) throw new Error("read paths must be non-empty strings");
219
+
178
220
  const results = await Promise.all(targetParam.map(async p => {
179
221
  try {
180
222
  const block = (await readAdapter({ ...params, path: p }, signal)).content[0];
223
+
181
224
  return { text: block.type === "image" ? block : block.text };
182
225
  } catch (error) {
183
226
  signal?.throwIfAborted();
227
+
184
228
  return { text: `[read error: ${p}] ${error.message}`, error: { path: p, message: error.message } };
185
229
  }
186
230
  }));
231
+
187
232
  signal?.throwIfAborted();
188
233
  const response = textResult("", { count: results.length, batch: true, independent: params._independent === true, items: results.map(r => r.text), itemErrors: results.map(r => r.error?.message ?? null), errors: results.filter(r => r.error).map(r => r.error) });
189
234
  response.isError = params._independent !== true && results.some(r => r.error);
235
+
190
236
  return response;
191
237
  }
238
+
192
239
  return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
193
240
  }
194
241
 
195
242
  async function resolveSessionResource(uri, signal) {
196
243
  const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
244
+
197
245
  if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
198
246
  const kind = match[1].toLowerCase();
199
247
  const id = decodeURIComponent(match[2]);
248
+
200
249
  if (!id || id === "." || id === ".." || (/[/\\]/u.test(id) || Array.from(id).some(char => char.charCodeAt(0) < 32)) || (kind === "artifact" && !/^\d+$/.test(id))) throw new Error("invalid session resource ID");
201
250
  const dir = hooks.artifactsDir?.();
251
+
202
252
  if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
203
253
  signal?.throwIfAborted();
204
254
  const root = await fs.realpath(dir);
205
255
  let file = id + ".md";
256
+
206
257
  if (kind === "artifact") {
207
258
  const matches = [];
208
259
  let count = 0;
260
+
209
261
  for await (const entry of await fs.opendir(root)) {
210
262
  signal?.throwIfAborted();
263
+
211
264
  if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
265
+
212
266
  if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
213
267
  }
268
+
214
269
  if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
215
270
  file = matches[0];
216
271
  }
272
+
217
273
  const target = await fs.realpath(path.join(root, file));
274
+
218
275
  if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
276
+
219
277
  if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
220
278
  signal?.throwIfAborted();
279
+
221
280
  return target;
222
281
  }
223
282
 
224
283
  async function readSingle(params, cwd, targetParam, signal) {
284
+ params = sessionJsonArgs({ ...params, path: targetParam });
285
+ validateJsonRead(params);
286
+ targetParam = params.path;
287
+
225
288
  if (isString(targetParam) && /^(?:agent|artifact):\/\//i.test(targetParam)) {
226
289
  const target = await resolveSessionResource(targetParam, signal);
290
+
227
291
  return params.resolve
228
292
  ? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
229
293
  : readFile(target, params, undefined, targetParam);
230
294
  }
295
+
231
296
  if (isString(params?.query)) {
232
297
  const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
298
+
233
299
  return sourceRead(params.query, scope, signal, params);
234
300
  }
301
+
235
302
  const existing = await probeExistingPath(cwd, targetParam, vfs);
303
+
236
304
  if (existing) {
237
305
  if (!existing.directory) return params.resolve
238
306
  ? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
239
307
  : readFile(existing.path, params);
308
+
309
+ if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
310
+
240
311
  return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
241
312
  }
242
- if (!looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
313
+
314
+ if (params.json === undefined && !looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
243
315
  const targetPath = resolveReadPath(cwd, targetParam);
316
+
244
317
  return readFile(targetPath, params);
245
318
  }
246
319
 
@@ -248,50 +321,93 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
248
321
  async function readFile(targetPath, params, sourceLine, displayPath) {
249
322
  const cwd = getCwd();
250
323
  const rel = displayPath ?? relativeSlash(cwd, targetPath);
324
+
325
+ if (params.json !== undefined) {
326
+ const project = jsonProjector(params.json);
327
+ const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES });
328
+ let document;
329
+
330
+ try { document = JSON.parse(text); }
331
+ catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
332
+
333
+ const many = Array.isArray(params.json);
334
+ let remaining = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256) - (many ? params.json.length + 1 : 0);
335
+ const parts = [];
336
+
337
+ for (const value of project(document)) {
338
+ const encoded = JSON.stringify(value);
339
+ remaining -= encoded.length;
340
+
341
+ if (remaining < 0) throw new Error("JSON selection exceeds the read budget; select narrower fields or an array slice such as .items[0:10]");
342
+ parts.push(encoded);
343
+ }
344
+
345
+ return textResult(many ? "[" + parts.join(",") + "]" : parts[0], { path: targetPath, json: true, complete: true });
346
+ }
347
+
251
348
  const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
349
+
252
350
  if (mime) {
253
351
  if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
254
352
  const staged = vfs.getOverlay(targetPath);
255
353
  const bytes = staged === undefined ? await fs.readFile(targetPath) : Buffer.from(staged);
354
+
256
355
  return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
257
356
  }
357
+
258
358
  const text = await vfs.read(targetPath);
259
359
  index.touch(rel);
360
+
260
361
  if (isString(params?.about)) {
261
362
  const entry = WorkspaceIndex.fromText(targetPath, text);
262
363
  const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
364
+
263
365
  if (outline) {
264
366
  recordOutlineOrigins(rel, outline.text);
367
+
265
368
  return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
266
369
  }
267
370
  }
371
+
268
372
  const explicit = isNumber(params?.offset) || isNumber(params?.limit);
269
373
  const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
270
374
  const offset = params?.offset ?? (sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1);
271
375
  const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
272
376
  const sliced = sliceLines(text, offset, params?.limit);
377
+
273
378
  if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
274
- throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use edit() for replacements or reconstruct resolve:true source windows`);
379
+ 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`);
275
380
  }
381
+
276
382
  if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
383
+ 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");
277
384
  let cap = budget - 160;
385
+
278
386
  if (params.resolve) {
279
387
  // Budget the actual JSON string, not a pessimistic fixed escape multiplier.
280
388
  let low = 0, high = Math.max(0, cap);
389
+
281
390
  while (low < high) {
282
391
  const mid = Math.ceil((low + high) / 2);
392
+
283
393
  if (JSON.stringify(sliced.slice(0, mid)).length <= budget - 160) low = mid;
284
394
  else high = mid - 1;
285
395
  }
396
+
286
397
  cap = low;
287
398
  }
399
+
288
400
  const end = sliced.lastIndexOf("\n", cap);
401
+
289
402
  if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
290
403
  const body = sliced.slice(0, end + 1);
291
404
  const next = firstLine + body.split("\n").length - 1;
405
+
292
406
  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 });
293
407
  }
408
+
294
409
  ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
410
+
295
411
  return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + sliced.split("\n").length - 1 - Number(sliced.endsWith("\n")), sourceChars: sliced.length, complete: sliced === text });
296
412
  }
297
413
 
@@ -304,27 +420,37 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
304
420
  const rel = relativeSlash(cwd, target);
305
421
  const newLines = updated.split("\n");
306
422
  const ranges = [];
423
+
307
424
  const positions = diff.lines.filter(row => row.type !== "context")
308
425
  .map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
426
+
309
427
  for (const line of positions) {
310
428
  const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
429
+
311
430
  if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
312
431
  else ranges.push({ start, end });
313
432
  }
433
+
314
434
  const blocks = [];
315
435
  const perRange = Math.max(1, Math.floor(40 / Math.max(1, ranges.length)));
436
+
316
437
  for (const { start, end } of ranges) {
317
438
  const last = Math.min(end, start + perRange - 1);
318
439
  const lines = newLines.slice(start - 1, last);
319
440
  ledger.recordOrigin(rel, start, lines);
320
441
  blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
442
+
321
443
  if (last < end) blocks.push("[continue with read({path:" + JSON.stringify(rel) + ",offset:" + (last + 1) + ",limit:" + (end - last) + "})]");
322
444
  }
445
+
323
446
  let out = blocks.join("\n");
324
447
  const check = quickCheck(updated, path.extname(target));
448
+
325
449
  if (check && !check.ok) out += `\ncheck: ${check.message}`;
326
450
  const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
451
+
327
452
  if (refs) out += `\n${refs}`;
453
+
328
454
  return out;
329
455
  }
330
456
 
@@ -334,30 +460,41 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
334
460
  const newLines = updated.split("\n");
335
461
  const names = new Set();
336
462
  const spans = new Map();
463
+
337
464
  for (const l of diff.lines) {
338
465
  if (l.type === "context") continue;
339
466
  const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
340
467
  const name = declaredName((l.type === "remove" ? oldLines : newLines)[number - 1] ?? "");
468
+
341
469
  if (name) names.add(name);
342
470
  else {
343
471
  if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
344
472
  const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
473
+
345
474
  if (owner?.name) names.add(owner.name);
346
475
  }
476
+
347
477
  if (names.size >= 3) break;
348
478
  }
479
+
349
480
  if (names.size === 0) return "";
481
+
350
482
  try {
351
483
  const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
352
484
  excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
485
+
353
486
  const parts = [];
487
+
354
488
  for (const [name, refs] of references) {
355
489
  if (refs.length) parts.push(name + " also referenced in " + refs.slice(0, 6).join(", ") + (refs.length > 6 ? " (more matches)" : ""));
356
490
  }
491
+
357
492
  if (incomplete) parts.push("references incomplete: search budget reached");
493
+
358
494
  return parts.join("\n");
359
495
  } catch (error) {
360
496
  signal?.throwIfAborted();
497
+
361
498
  return "references unavailable: " + error.message;
362
499
  }
363
500
  }
@@ -368,20 +505,27 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
368
505
  async function sourceWindow(cwd, commandCwd, file, lineNo) {
369
506
  const candidate = path.resolve(commandCwd, file);
370
507
  let text, rel;
508
+
371
509
  try {
372
510
  const root = await fs.realpath(cwd);
511
+
373
512
  if (!candidate.startsWith(path.resolve(cwd) + path.sep) && !candidate.startsWith(root + path.sep)) return null;
374
513
  const real = await fs.realpath(candidate);
514
+
375
515
  if (!real.startsWith(root + path.sep) || (await fs.stat(real)).size > 1024 * 1024) return null;
376
516
  text = await fs.readFile(real, "utf8");
377
517
  rel = relativeSlash(root, real);
378
518
  } catch { return null; }
519
+
379
520
  const raw = text.split("\n");
521
+
380
522
  if (lineNo < 1 || lineNo > raw.length) return null;
381
523
  const start = Math.max(1, lineNo - 2);
382
524
  const rows = [];
525
+
383
526
  for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
384
527
  ledger.recordOrigin(rel, start, rows);
528
+
385
529
  return rel + ":" + lineNo + "\n" + rows.join("\n");
386
530
  }
387
531
 
@@ -389,20 +533,27 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
389
533
  async function sourceForReferences(cwd, commandCwd, output) {
390
534
  const seen = new Set();
391
535
  const blocks = [];
536
+
392
537
  for (const m of output.matchAll(SOURCE_REF)) {
393
538
  const key = m[1] + ":" + m[2];
539
+
394
540
  if (seen.has(key)) continue;
541
+
395
542
  if (seen.size >= 4) break;
396
543
  seen.add(key);
397
544
  const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
545
+
398
546
  if (block) blocks.push(block);
399
547
  }
548
+
400
549
  return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
401
550
  }
402
551
 
403
552
  function outlineOptions(params, references) {
404
553
  const options = { references };
554
+
405
555
  if (params?.maxChars) options.maxChars = params.maxChars;
556
+
406
557
  return options;
407
558
  }
408
559
 
@@ -410,6 +561,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
410
561
  function recordOutlineOrigins(rel, outlineText) {
411
562
  for (const line of outlineText.split("\n")) {
412
563
  const m = /^\s*(\d+) (.*)$/.exec(line);
564
+
413
565
  if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
414
566
  }
415
567
  }
@@ -417,11 +569,14 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
417
569
  /** Where else a name appears (declaration line excluded), for outlines and edit results. */
418
570
  async function referenceFinder(cwd, targetPath) {
419
571
  const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
572
+
420
573
  if (!index.canScan(files)) return () => [];
574
+
421
575
  return (name, excludeLine) => {
422
576
  if (!name || name.length < 3) return [];
423
577
  const escaped = name.replace(/[$]/g, (c) => "\\" + c);
424
578
  const regex = new RegExp("\\b" + escaped + "\\b");
579
+
425
580
  return index
426
581
  .grepRows(files, regex, cwd, file => vfs.getOverlay(file))
427
582
  .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
@@ -430,22 +585,30 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
430
585
  }
431
586
 
432
587
  hooks.summarizeEdit = editSummary;
588
+
433
589
  return {
434
590
  read: readAdapter,
435
591
  async write(params, signal) {
436
592
  const cwd = getCwd();
437
593
  const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
594
+
438
595
  if (signal?.aborted) throw new Error("aborted");
596
+
439
597
  if (!isString(params?.content)) throw new Error("write requires string content");
598
+
440
599
  if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
441
600
  let content = params.content;
601
+
442
602
  if (params.allowReadArtifacts !== true && /\[read truncated;|…\[(?:host-result|output|value) truncated \d+ chars\]…/u.test(content)) {
443
603
  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");
444
604
  }
605
+
445
606
  let prevText = "";
607
+
446
608
  try {
447
609
  prevText = await vfs.read(target, { preserveRead: true });
448
610
  } catch (error) { if (error.code !== "ENOENT") throw error; }
611
+
449
612
  if (params.append === true) content = prevText + content;
450
613
  const { speculative } = await vfs.write(target, content);
451
614
  index.touch(relativeSlash(cwd, target));
@@ -453,38 +616,49 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
453
616
  const tag = speculative ? " (speculative)" : "";
454
617
  const check = quickCheck(content, path.extname(target));
455
618
  const warning = check && !check.ok ? "\ncheck: " + check.message : "";
619
+
456
620
  return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
457
621
  },
458
622
  async edit(params, signal) {
459
623
  const cwd = getCwd();
460
624
  const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
625
+
461
626
  if (signal?.aborted) throw new Error("aborted");
462
627
 
463
628
  const requestedEdits = Array.isArray(params?.edits)
464
629
  ? params.edits
465
630
  : [{ oldText: params?.oldText, newText: params?.newText }];
631
+
466
632
  const content = await vfs.read(target);
467
633
  const { updated, matches } = applyReplacements(target, content, requestedEdits);
468
634
  const { speculative } = await vfs.write(target, updated);
469
635
  index.touch(relativeSlash(cwd, target));
636
+
470
637
  const diff =
471
638
  matches.length === 1
472
639
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
473
640
  : buildMultiEditDiff(target, content, matches);
641
+
474
642
  const summary = await editSummary(cwd, target, content, updated, diff, signal);
643
+
475
644
  return textResult(summary, { path: target, speculative, diff });
476
645
  },
477
646
  async apply_patch(params, signal) {
478
647
  const cwd = getCwd();
479
648
  let inputPath = params?.path;
649
+
480
650
  if (!inputPath && isString(params?.patch)) {
481
651
  const headerMatch = /^\+\+\+\s+[ab]\/(.+)$/m.exec(params.patch) || /^---\s+[ab]\/(.+)$/m.exec(params.patch);
652
+
482
653
  if (headerMatch) inputPath = headerMatch[1].trim();
483
654
  }
655
+
484
656
  const target = await resolveWorkspacePath(cwd, inputPath, "apply_patch", false);
657
+
485
658
  if (!isString(params?.patch) || !params.patch.trim()) {
486
659
  throw new Error("apply_patch requires patch");
487
660
  }
661
+
488
662
  if (signal?.aborted) throw new Error("aborted");
489
663
 
490
664
  const original = await vfs.read(target);
@@ -493,6 +667,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
493
667
  const diff = buildPatchDiff(target, params.patch);
494
668
  index.touch(relativeSlash(cwd, target));
495
669
  const summary = await editSummary(cwd, target, original, resultText, diff, signal);
670
+
496
671
  return textResult(summary, {
497
672
  path: target,
498
673
  hunks: hunkCount,
@@ -502,15 +677,19 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
502
677
  },
503
678
  async snap(params, signal) {
504
679
  const cwd = getCwd();
680
+
505
681
  if (!isString(params?.query) || !params.query.trim()) {
506
682
  throw new Error("snap requires query");
507
683
  }
684
+
508
685
  if (signal?.aborted) throw new Error("aborted");
509
686
  const snapTarget = params?.path ? await resolveWorkspacePath(cwd, params.path, "snap", true) : cwd;
510
687
  const relativeRoot = path.relative(cwd, snapTarget);
688
+
511
689
  const includeHidden = Boolean(params?.path) && relativeRoot
512
690
  .split(path.sep)
513
691
  .some((segment) => segment.startsWith(".") && segment.length > 1);
692
+
514
693
  const res = await executeSnap({
515
694
  query: params.query,
516
695
  searchDir: snapTarget,
@@ -520,34 +699,45 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
520
699
  pendingPaths: vfs.getOverlayPaths(),
521
700
  signal,
522
701
  });
702
+
523
703
  return textResult(JSON.stringify(res, null, 2), res);
524
704
  },
525
705
  async evidence(params, signal) {
526
706
  const cwd = getCwd();
707
+
527
708
  if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
709
+
528
710
  if (signal?.aborted) throw new Error("aborted");
529
711
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
530
712
  const options = {};
713
+
531
714
  if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
715
+
532
716
  if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
533
717
  const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
718
+
534
719
  for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
720
+
535
721
  return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
536
722
  },
537
723
  async surface(params, signal) {
538
724
  const cwd = getCwd();
539
725
  const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
726
+
540
727
  if (signal?.aborted) throw new Error("aborted");
541
728
  const text = await vfs.read(target);
542
729
  const ext = path.extname(target);
543
730
  const outline = extractStructuralSurface(text, ext);
731
+
544
732
  return textResult(JSON.stringify(outline, null, 2), { path: target, count: outline.items.length });
545
733
  },
546
734
  async bash(params, signal) {
547
735
  const cwd = getCwd();
548
736
  const literal = params?._directArgv === true;
737
+
549
738
  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");
550
739
  const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
740
+
551
741
  if (!command.trim()) throw new Error("bash requires command");
552
742
  const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
553
743
 
@@ -556,6 +746,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
556
746
 
557
747
  const transactionBarrier = await vfs.prepareExternalMutation("bash");
558
748
  let res;
749
+
559
750
  try {
560
751
  res = await runCommand(argv, {
561
752
  cwd: targetCwd,
@@ -574,9 +765,12 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
574
765
  clearPathCache();
575
766
  hooks.workspaceChanged();
576
767
  }
768
+
577
769
  const { stdout, stderr } = res;
578
770
  let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
771
+
579
772
  if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
773
+
580
774
  return {
581
775
  content: [{ type: "text", text }],
582
776
  details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -586,66 +780,85 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
586
780
  async grep(params, signal) {
587
781
  const cwd = getCwd();
588
782
  const pattern = String(params?.pattern || "");
783
+
589
784
  if (!pattern) throw new Error("grep requires pattern");
590
785
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
591
786
  const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
787
+
592
788
  if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
593
789
  // Large tree: real rg keeps its own output format.
594
790
  const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
791
+
595
792
  if (res.exitCode !== 0 && res.exitCode !== 1) {
596
793
  throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
597
794
  }
795
+
598
796
  return textResult(res.stdout, { exitCode: res.exitCode });
599
797
  },
600
798
  async glob(params, signal) {
601
799
  const cwd = getCwd();
602
800
  const pattern = String(params?.pattern || "");
801
+
603
802
  if (!pattern) throw new Error("glob requires pattern");
604
803
  const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
804
+
605
805
  if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
606
806
  const indexed = await listIndexed(index, cwd, cwd, pattern);
807
+
607
808
  if (indexed !== null) return textResult(indexed, { via: "index" });
809
+
608
810
  const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
609
811
  () => null,
610
812
  );
813
+
611
814
  if (rg && (rg.exitCode === 0 || rg.exitCode === 1)) {
612
815
  return textResult(rg.stdout, { via: "rg" });
613
816
  }
817
+
614
818
  const findPattern = pattern.startsWith("./") ? pattern : `./${pattern}`;
819
+
615
820
  const fallback = await runCommand(["find", ".", "-type", "f", "-path", findPattern], {
616
821
  cwd,
617
822
  timeoutMs: 30_000,
618
823
  signal,
619
824
  });
825
+
620
826
  return textResult(fallback.stdout, { via: "find" });
621
827
  },
622
828
  async find(params, signal) {
623
829
  const cwd = getCwd();
624
830
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
625
831
  const pattern = params?.pattern || params?.glob;
832
+
626
833
  if (signal?.aborted) throw new Error("aborted");
627
834
  const globPattern = pattern ? String(pattern) : null;
628
835
  const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
836
+
629
837
  if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
630
838
  const indexed = await listIndexed(index, searchDir, cwd, globPattern);
839
+
631
840
  if (indexed !== null) return textResult(indexed, { via: "index" });
841
+
632
842
  return listWithTools(searchDir, globPattern, cwd, signal);
633
843
  },
634
844
  async ls(params, signal) {
635
845
  const cwd = getCwd();
636
846
  const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
847
+
637
848
  return readDirectory(dirPath, signal);
638
849
  },
639
850
  };
640
851
  }
641
852
 
642
- export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
853
+ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
643
854
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
644
- const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
855
+ const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
856
+
645
857
  const vfs = new CausalVfs(paths => {
646
858
  index.invalidate();
647
859
  notifyWorkspaceChanged(paths);
648
860
  }, target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
861
+
649
862
  const executors = registry?.executors ?? new Map();
650
863
  const definitions = registry?.definitions ?? new Map();
651
864
  const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
@@ -660,20 +873,25 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
660
873
  let trace = [];
661
874
  let callListener = null;
662
875
  const scheduler = createNativeScheduler();
876
+
663
877
  // Advisory host event, not a tool or a transaction participant. Consumers
664
878
  // invalidate synchronously; failures must never affect committed bytes.
665
879
  function notifyWorkspaceChanged(paths = null) {
666
880
  if (!isFunction(pi?.events?.emit)) return;
881
+
667
882
  const event = Object.freeze({
668
883
  version: 1, cwd: path.resolve(getCwd()),
669
884
  paths: paths === null ? null : Object.freeze([...new Set(paths)]),
670
885
  });
886
+
671
887
  try { pi.events.emit("workspace:changed", event)?.catch?.(() => {}); } catch {}
672
888
  }
889
+
673
890
  hooks.workspaceChanged = notifyWorkspaceChanged;
674
891
  hooks.artifactsDir = () => activeCtx?.sessionManager?.getArtifactsDir?.();
675
892
  hooks.commandEnv = () => {
676
893
  const env = { ...process.env };
894
+
677
895
  const current = {
678
896
  PI_SESSION_ID: activeCtx?.sessionManager?.getSessionId?.(),
679
897
  PI_SESSION_FILE: activeCtx?.sessionManager?.getSessionFile?.(),
@@ -681,10 +899,12 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
681
899
  PI_MODEL: activeCtx?.model?.id,
682
900
  PI_REASONING_LEVEL: activeCtx?.thinkingLevel,
683
901
  };
902
+
684
903
  for (const [key, value] of Object.entries(current)) {
685
904
  if (isString(value)) env[key] = value;
686
905
  else delete env[key];
687
906
  }
907
+
688
908
  return env;
689
909
  };
690
910
 
@@ -702,6 +922,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
702
922
  executors.set(tool.name, tool.execute.bind(tool));
703
923
  definitions.set(tool.name, tool);
704
924
  }
925
+
705
926
  return original(tool);
706
927
  };
707
928
  }
@@ -721,33 +942,45 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
721
942
  function hostTool(name) {
722
943
  if (!hostSession) return undefined;
723
944
  const metadata = definitions.get(name);
945
+
724
946
  // Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
725
947
  if (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
948
+
726
949
  return hostSession.getToolForEvalBridge?.(name);
727
950
  }
728
951
 
729
952
  function isCallable(name) {
730
953
  if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
954
+
731
955
  if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
956
+
732
957
  // An internal adapter belongs to Supernova, not the host's visible tool list.
733
958
  const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
734
959
  && (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin");
960
+
735
961
  if (nativeOwned) return true;
962
+
736
963
  if (hostSession) {
737
964
  if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
965
+
738
966
  return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
739
967
  }
968
+
740
969
  if (definitions.has(name) && isFunction(pi?.getActiveTools) && !pi.getActiveTools().includes(name)) return false;
970
+
741
971
  return executors.has(name) || Object.hasOwn(natives, name);
742
972
  }
743
973
 
744
974
  function refreshTools() {
745
975
  const tools = pi?.getAllTools?.() ?? [];
976
+
746
977
  for (const tool of tools) {
747
978
  if (!isString(tool?.name)) continue;
748
979
  definitions.set(tool.name, { ...definitions.get(tool.name), ...tool });
980
+
749
981
  if (!hostSession && isFunction(tool.execute)) executors.set(tool.name, tool.execute.bind(tool));
750
982
  }
983
+
751
984
  return [...definitions.values()].filter(tool => isCallable(tool.name));
752
985
  }
753
986
 
@@ -787,6 +1020,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
787
1020
 
788
1021
  function resultDiff(response) {
789
1022
  let details = response?.details;
1023
+
790
1024
  if (isString(details)) {
791
1025
  try {
792
1026
  details = JSON.parse(details);
@@ -794,11 +1028,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
794
1028
  return undefined;
795
1029
  }
796
1030
  }
1031
+
797
1032
  return isObject(details) ? details.diff : undefined;
798
1033
  }
799
1034
 
800
1035
  function notifyCall(record) {
801
1036
  if (!callListener) return;
1037
+
802
1038
  try {
803
1039
  callListener(record, [...trace]);
804
1040
  } catch {}
@@ -807,19 +1043,25 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
807
1043
  function checkCallBudget(name) {
808
1044
  if (closed) throw new Error("program is already complete");
809
1045
  const maxCalls = config.maxBridgeCalls ?? 256;
1046
+
1047
+ if (budget && ++budget.calls > maxCalls) throw new Error("host call budget exceeded (" + maxCalls + " calls per program batch): split the batch");
810
1048
  callCount += 1;
1049
+
811
1050
  if (callCount > maxCalls) {
812
1051
  throw new Error(
813
1052
  `host call budget exceeded (${maxCalls} calls per program): split the work across programs`,
814
1053
  );
815
1054
  }
1055
+
816
1056
  if (activeSignal?.aborted) throw new Error("aborted");
1057
+
817
1058
  if (!isString(name) || !name) throw new Error("tool name required");
818
1059
  }
819
1060
 
820
1061
  function assertCallableTarget(name) {
821
1062
  // Never re-enter supernova or other excluded composition tools via the bridge.
822
1063
  const excluded = new Set(config.excludeTools || []);
1064
+
823
1065
  if (name === "supernova" || excluded.has(name)) {
824
1066
  throw new Error(
825
1067
  `${name} is blocked (excluded / non-reentrant).`,
@@ -831,17 +1073,20 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
831
1073
  if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
832
1074
  const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
833
1075
  let previous = "";
1076
+
834
1077
  try {
835
1078
  previous = await vfs.read(target);
836
1079
  } catch (error) {
837
1080
  if (error?.code !== "ENOENT") throw error;
838
1081
  }
1082
+
839
1083
  return buildWriteDiff(target, previous, args.content);
840
1084
  }
841
1085
 
842
1086
  function completeRecord(record, res, fallbackDiff) {
843
1087
  const diff = resultDiff(res) || fallbackDiff;
844
1088
  finishRecord(record, res);
1089
+
845
1090
  if (diff && record.ok) record.diff = diff;
846
1091
  notifyCall(record);
847
1092
  }
@@ -850,6 +1095,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
850
1095
  checkCallBudget(name);
851
1096
  const callId = ++sharedRegistry.callSeq;
852
1097
  assertCallableTarget(name);
1098
+
853
1099
  if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
854
1100
 
855
1101
  const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
@@ -860,18 +1106,27 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
860
1106
  try {
861
1107
  const delegated = hostTool(name);
862
1108
  const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
1109
+
863
1110
  if (exec) {
1111
+ 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");
1112
+
864
1113
  if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
865
1114
  const fallbackDiff = await writeFallbackDiff(name, args);
866
1115
  const mutating = isMutatingTool(name, config, args, definitions.get(name));
1116
+
867
1117
  if (mutating) await vfs.prepareExternalMutation(name);
1118
+
868
1119
  if (activeSignal?.aborted || closed) throw new Error("aborted");
1120
+
869
1121
  if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
1122
+
870
1123
  try {
871
1124
  const res = await exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, delegated
872
1125
  ? { ...activeCtx, settings: hostSession.settings, toolNames: hostSession.getEvalBridgeToolNames(), autoApprove: false }
873
1126
  : activeCtx);
1127
+
874
1128
  completeRecord(record, res, fallbackDiff);
1129
+
875
1130
  return res;
876
1131
  } finally {
877
1132
  if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); notifyWorkspaceChanged(); }
@@ -879,9 +1134,11 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
879
1134
  }
880
1135
 
881
1136
  const native = Object.hasOwn(natives, name) ? natives[name] : undefined;
1137
+
882
1138
  if (native) {
883
1139
  const res = await native(args || {}, activeSignal);
884
1140
  completeRecord(record, res);
1141
+
885
1142
  return res;
886
1143
  }
887
1144
 
@@ -899,6 +1156,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
899
1156
  record.ms = Date.now() - record.time;
900
1157
  record.ok = !hostResultFailed(res);
901
1158
  const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
1159
+
902
1160
  if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
903
1161
  }
904
1162
 
@@ -906,18 +1164,23 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
906
1164
  if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
907
1165
  const invoke = async () => packageHostResult(await invokeRaw(name, args), config);
908
1166
  const kind = isMutatingTool(name, config, args, definitions.get(name)) ? "write" : "read";
1167
+
909
1168
  return scheduler.schedule(kind, invoke, activeSignal);
910
1169
  }
911
1170
 
912
1171
  async function callMany(calls) {
913
1172
  if (!Array.isArray(calls)) throw new TypeError("nova.callMany requires an array");
914
1173
  const list = calls;
1174
+
915
1175
  if (list.some(item => !isString(item?.name) || !item.name)) throw new TypeError("nova.callMany entries require a tool name");
1176
+
916
1177
  const thunks = list.map((item) => {
917
1178
  const n = item?.name;
918
1179
  const a = item?.args;
1180
+
919
1181
  return () => call(n, a);
920
1182
  });
1183
+
921
1184
  const names = list.map((item) => item?.name).filter((n) => isString(n));
922
1185
  const wave = await runParallelWave(thunks, { names, calls: list, definitions: names.map(name => definitions.get(name)) }, { mode: "auto", config });
923
1186
  // Return a results array that also carries .mode/.reason, and is directly
@@ -928,6 +1191,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
928
1191
  reason: { value: wave.reason, enumerable: false },
929
1192
  results: { value: results, enumerable: false },
930
1193
  });
1194
+
931
1195
  return results;
932
1196
  }
933
1197
 
@@ -955,12 +1219,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
955
1219
  },
956
1220
  },
957
1221
  fork(options) {
958
- return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork() });
1222
+ return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
959
1223
  },
960
1224
  close() { closed = true; vfs.closed = true; },
961
1225
  bindCallContext,
962
1226
  resetCallBudget,
963
1227
  getTrace,
1228
+ getMutations: () => ({ ...vfs.mutations }),
964
1229
  setCallListener,
965
1230
  barrier: run => scheduler.schedule("write", run, activeSignal),
966
1231
  beginSpeculation,