pi-supernova 0.3.2 → 0.5.0

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