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