pi-supernova 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host-bridge.js CHANGED
@@ -1,13 +1,16 @@
1
1
 
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { spawn } from "node:child_process";
5
4
  import { packageHostResult } from "./bottleneck.js";
6
5
  import { isString, isNumber, isFunction, isObject } from "./decode.js";
7
6
  import { isMutatingTool, runParallelWave } from "./parallel.js";
7
+ import { unknownToolMessage } from "./catalog.js";
8
8
  import { extractStructuralSurface } from "./surface.js";
9
9
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
10
  import { executeSnap } from "./snap.js";
11
+ import { CausalVfs } from "./vfs.js";
12
+ import { applyPatchToText } from "./patch.js";
13
+ import { resolveWorkspacePath, runCommand } from "./workspace.js";
11
14
 
12
15
  function textResult(text, details) {
13
16
  return {
@@ -27,338 +30,87 @@ function unwrapIfFullyQuoted(s) {
27
30
  return inner;
28
31
  }
29
32
 
30
- let cachedCwd = null;
31
- let cachedResolvedCwd = null;
32
-
33
- function getResolvedCwd(cwd) {
34
- if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
35
- cachedCwd = cwd;
36
- cachedResolvedCwd = path.resolve(cwd);
37
- return cachedResolvedCwd;
38
- }
39
-
40
- async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
41
- if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
42
- throw new Error(`${opName} requires path`);
43
- }
44
- const resolvedCwd = getResolvedCwd(cwd);
45
- const target = path.resolve(resolvedCwd, inputPath.trim());
46
- const rel = path.relative(resolvedCwd, target);
47
- if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
48
- throw new Error(`${opName} path escapes workspace`);
49
- }
50
- if (!allowRoot && target === resolvedCwd) {
51
- throw new Error(`${opName} path cannot be the workspace root directory`);
52
- }
53
-
54
- const realRoot = await fs.realpath(resolvedCwd);
55
- let probe = target;
56
- while (true) {
57
- try {
58
- probe = await fs.realpath(probe);
59
- break;
60
- } catch (err) {
61
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
62
- const parent = path.dirname(probe);
63
- if (parent === probe) throw err;
64
- probe = parent;
65
- }
66
- }
67
- const realRel = path.relative(realRoot, probe);
68
- if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
69
- throw new Error(`${opName} path escapes workspace through symlink`);
70
- }
71
- return target;
72
- }
73
-
74
- async function runCommand(argv, options = {}) {
75
- const cwd = options.cwd || process.cwd();
76
- const timeoutMs = options.timeoutMs ?? 60_000;
77
- const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
78
- return await new Promise((resolve, reject) => {
79
- const child = spawn(argv[0], argv.slice(1), {
80
- cwd,
81
- env: process.env,
82
- stdio: ["ignore", "pipe", "pipe"],
83
- });
84
- let stdout = "";
85
- let stderr = "";
86
- let settled = false;
87
- let outputTruncated = false;
88
- let onAbort;
89
-
90
- const cleanup = () => {
91
- clearTimeout(timer);
92
- if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
93
- };
94
- const fail = (err) => {
95
- if (settled) return;
96
- settled = true;
97
- cleanup();
98
- reject(err);
99
- };
100
- const append = (current, chunk) => {
101
- const remaining = Math.max(0, maxOutputChars - current.length);
102
- if (chunk.length > remaining) outputTruncated = true;
103
- return remaining > 0 ? current + chunk.slice(0, remaining) : current;
104
- };
105
- const timer = setTimeout(() => {
106
- child.kill("SIGTERM");
107
- fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
108
- }, timeoutMs);
109
-
110
- child.stdout.setEncoding("utf8");
111
- child.stderr.setEncoding("utf8");
112
- child.stdout.on("data", (chunk) => {
113
- stdout = append(stdout, chunk);
114
- });
115
- child.stderr.on("data", (chunk) => {
116
- stderr = append(stderr, chunk);
117
- });
118
- child.on("error", fail);
119
- child.on("close", (code) => {
120
- if (settled) return;
121
- settled = true;
122
- cleanup();
123
- resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
124
- });
125
- if (options.signal) {
126
- onAbort = () => {
127
- child.kill("SIGTERM");
128
- fail(new Error("aborted"));
129
- };
130
- if (options.signal.aborted) onAbort();
131
- else options.signal.addEventListener("abort", onAbort, { once: true });
132
- }
133
- });
33
+ function sliceLines(text, offset, limit) {
34
+ if (!isNumber(offset) && !isNumber(limit)) return text;
35
+ const lines = text.split("\n");
36
+ const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
37
+ const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
38
+ return lines.slice(startIndex, startIndex + count).join("\n");
134
39
  }
135
40
 
136
- function parseHunkHeader(line) {
137
- const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
138
- if (!match) return null;
139
- return {
140
- oldStart: parseInt(match[1], 10),
141
- oldLength: match[2] !== undefined ? parseInt(match[2], 10) : 1,
142
- newStart: parseInt(match[3], 10),
143
- newLength: match[4] !== undefined ? parseInt(match[4], 10) : 1,
144
- lines: [],
145
- };
146
- }
147
-
148
- export function parsePatchHunks(patchText) {
149
- const patchLines = patchText.replace(/\r\n/g, "\n").split("\n");
150
- const hunks = [];
151
- let current = null;
152
-
153
- for (const line of patchLines) {
154
- const header = parseHunkHeader(line);
155
- if (header) {
156
- if (current) hunks.push(current);
157
- current = header;
158
- } else if (current && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
159
- current.lines.push(line);
160
- }
161
- }
162
- if (current) hunks.push(current);
163
- if (hunks.length === 0) {
164
- throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
165
- }
166
- return hunks;
41
+ function looksLikePath(target) {
42
+ return (
43
+ isString(target) &&
44
+ (target.includes("/") ||
45
+ target.includes("\\") ||
46
+ target.startsWith(".") ||
47
+ (!/\s/.test(target) && path.extname(target).length > 0))
48
+ );
167
49
  }
168
50
 
169
- function findHunkMatch(fileLines, expectedOld, nominal) {
170
- const matchAt = (idx) => {
171
- if (idx < 0 || idx + expectedOld.length > fileLines.length) return false;
172
- for (let j = 0; j < expectedOld.length; j++) {
173
- if (fileLines[idx + j] !== expectedOld[j]) return false;
174
- }
175
- return true;
176
- };
177
-
178
- if (matchAt(nominal)) return nominal;
179
- const maxDelta = Math.max(fileLines.length, 100);
180
- for (let delta = 1; delta <= maxDelta; delta++) {
181
- if (matchAt(nominal + delta)) return nominal + delta;
182
- if (matchAt(nominal - delta)) return nominal - delta;
51
+ async function probeExistingFile(cwd, targetParam, vfs) {
52
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
53
+ if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return targetPath;
54
+ try {
55
+ const st = await fs.stat(targetPath);
56
+ if (st.isDirectory()) throw new Error(`read path is a directory, not a file: ${targetPath} (use ls)`);
57
+ return targetPath;
58
+ } catch (err) {
59
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
60
+ return null;
183
61
  }
184
- return -1;
185
62
  }
186
63
 
187
- export function applyPatchToText(originalText, patchText) {
188
- if (!isString(patchText) || !patchText.trim()) {
189
- throw new Error("apply_patch requires non-empty patch");
190
- }
191
-
192
- const hunks = parsePatchHunks(patchText);
193
- let fileLines = originalText.replace(/\r\n/g, "\n").split("\n");
194
- const hasTrailingNewline = originalText.endsWith("\n");
195
- let offsetShift = 0;
196
-
197
- for (let h = 0; h < hunks.length; h++) {
198
- const hunk = hunks[h];
199
- const expectedOld = [];
200
- const newLines = [];
201
-
202
- for (const hLine of hunk.lines) {
203
- if (hLine.startsWith("-")) {
204
- expectedOld.push(hLine.slice(1));
205
- } else if (hLine.startsWith("+")) {
206
- newLines.push(hLine.slice(1));
207
- } else {
208
- const val = hLine.startsWith(" ") ? hLine.slice(1) : "";
209
- expectedOld.push(val);
210
- newLines.push(val);
211
- }
212
- }
213
-
214
- if (expectedOld.length !== hunk.oldLength || newLines.length !== hunk.newLength) {
215
- throw new Error(`patch hunk ${h + 1} length does not match its header`);
216
- }
217
-
218
- const nominal = Math.max(0, hunk.oldStart - 1 + offsetShift);
219
- const matchIdx = findHunkMatch(fileLines, expectedOld, nominal);
220
- if (matchIdx === -1) {
221
- throw new Error(`patch hunk ${h + 1} rejected at line ${hunk.oldStart}: context did not match`);
222
- }
223
-
224
- fileLines.splice(matchIdx, expectedOld.length, ...newLines);
225
- offsetShift += (matchIdx - nominal) + (newLines.length - expectedOld.length);
226
- }
227
-
228
- let resultText = fileLines.join("\n");
229
- if (hasTrailingNewline && !resultText.endsWith("\n")) resultText += "\n";
230
- return { resultText, hunkCount: hunks.length };
64
+ function patchModeOf(params) {
65
+ return (
66
+ isString(params?.patch) ||
67
+ (params?.newText === undefined &&
68
+ isString(params?.oldText) &&
69
+ (params.oldText.includes("@@ -") || params.oldText.startsWith("---")))
70
+ );
231
71
  }
232
72
 
233
- const VFS_CACHE_MAX = 1024;
234
-
235
- class CausalVfs {
236
- constructor() {
237
- this.cache = new Map();
238
- this.overlays = [];
239
- }
240
-
241
- setCache(target, content) {
242
- if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
243
- const oldest = this.cache.keys().next().value;
244
- if (oldest !== undefined) this.cache.delete(oldest);
73
+ function applyReplacements(target, content, requestedEdits) {
74
+ if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
75
+ const matches = requestedEdits.map((replacement) => {
76
+ if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
77
+ throw new Error("edit requires non-empty oldText");
245
78
  }
246
- this.cache.set(target, content);
247
- }
248
-
249
- getOverlay(target) {
250
- for (let i = this.overlays.length - 1; i >= 0; i--) {
251
- if (this.overlays[i].has(target)) return this.overlays[i].get(target);
79
+ if (!isString(replacement?.newText)) throw new Error("edit requires newText");
80
+ const index = content.indexOf(replacement.oldText);
81
+ if (index < 0) {
82
+ throw new Error(`edit target not found in ${target}: oldText must match the file byte-for-byte (read() it first; check whitespace and quotes)`);
252
83
  }
253
- return undefined;
254
- }
255
-
256
- getOverlayPaths() {
257
- const paths = new Set();
258
- for (const overlay of this.overlays) {
259
- for (const target of overlay.keys()) paths.add(target);
84
+ if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
85
+ throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
260
86
  }
261
- return [...paths];
262
- }
263
-
264
- async read(target) {
265
- const overlay = this.getOverlay(target);
266
- if (overlay !== undefined) return overlay;
267
-
268
- const cached = this.cache.get(target);
269
- if (cached !== undefined) return cached;
270
-
271
- try {
272
- const text = await fs.readFile(target, "utf8");
273
- this.setCache(target, text);
274
- return text;
275
- } catch (err) {
276
- if (err.code === "EISDIR") {
277
- throw new Error(`read path is a directory, not a file: ${target}`);
278
- }
279
- throw err;
280
- }
281
- }
282
-
283
- async write(target, content) {
284
- if (this.overlays.length > 0) {
285
- this.overlays[this.overlays.length - 1].set(target, content);
286
- return { speculative: true };
287
- }
288
-
289
- try {
290
- const stat = await fs.stat(target);
291
- if (stat.isDirectory()) {
292
- throw new Error(`cannot write to a directory: ${target}`);
293
- }
294
- } catch (err) {
295
- if (err.code !== "ENOENT") throw err;
296
- }
297
-
298
- await fs.mkdir(path.dirname(target), { recursive: true });
299
- await fs.writeFile(target, content, "utf8");
300
- this.setCache(target, content);
301
- return { speculative: false };
302
- }
303
-
304
- begin() {
305
- this.overlays.push(new Map());
306
- return this.overlays.length;
307
- }
308
-
309
- async commit() {
310
- if (this.overlays.length === 0) return { committed: 0, depth: 0 };
311
- const top = this.overlays.pop();
312
- if (this.overlays.length > 0) {
313
- const parent = this.overlays[this.overlays.length - 1];
314
- for (const [k, v] of top.entries()) parent.set(k, v);
315
- return { committed: top.size, depth: this.overlays.length };
316
- }
317
- for (const [filePath, fileContent] of top.entries()) {
318
- await fs.mkdir(path.dirname(filePath), { recursive: true });
319
- await fs.writeFile(filePath, fileContent, "utf8");
320
- this.setCache(filePath, fileContent);
321
- }
322
- return { committed: top.size, depth: 0 };
87
+ return { ...replacement, index, end: index + replacement.oldText.length };
88
+ });
89
+ matches.sort((a, b) => a.index - b.index);
90
+ for (let i = 1; i < matches.length; i++) {
91
+ if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
323
92
  }
324
-
325
- rollback() {
326
- if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
327
- const top = this.overlays.pop();
328
- return { rolledBack: top.size, depth: this.overlays.length };
93
+ let updated = content;
94
+ for (let i = matches.length - 1; i >= 0; i--) {
95
+ const match = matches[i];
96
+ updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
329
97
  }
98
+ return { updated, matches };
99
+ }
330
100
 
331
- async prepareExternalMutation(name) {
332
- if (this.overlays.length > 1) {
333
- throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
101
+ async function formatLsEntry(dirPath, entry) {
102
+ const isDir = entry.isDirectory();
103
+ const isSym = entry.isSymbolicLink();
104
+ const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
105
+ let size = 0;
106
+ try {
107
+ if (!isDir && !isSym) {
108
+ const st = await fs.stat(path.join(dirPath, entry.name));
109
+ size = st.size;
334
110
  }
335
- if (this.overlays.length === 0) return false;
336
- const pending = this.overlays[0];
337
- for (const [filePath, fileContent] of pending.entries()) {
338
- await fs.mkdir(path.dirname(filePath), { recursive: true });
339
- await fs.writeFile(filePath, fileContent, "utf8");
340
- this.setCache(filePath, fileContent);
341
- }
342
- this.overlays[0] = new Map();
343
- return pending.size > 0;
344
- }
345
-
346
- invalidateCache() {
347
- this.cache.clear();
348
- }
349
-
350
- clear() {
351
- this.invalidateCache();
352
- this.overlays.length = 0;
353
- }
354
-
355
- getCacheSize() {
356
- return this.cache.size;
357
- }
358
-
359
- getOverlayDepth() {
360
- return this.overlays.length;
361
- }
111
+ } catch {}
112
+ const sizeSuffix = size ? `, ${size} bytes` : "";
113
+ return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
362
114
  }
363
115
 
364
116
  function createNativeAdapters(getCwd, vfs, config) {
@@ -370,57 +122,20 @@ function createNativeAdapters(getCwd, vfs, config) {
370
122
  const results = await Promise.all(
371
123
  targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
372
124
  );
373
- return textResult(results.map((r) => r.value).join("\n---\n"), {
374
- count: results.length,
375
- batch: true,
376
- items: results.map((r) => r.value),
377
- });
125
+ const items = results.map((r) => r.content[0].text);
126
+ return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
378
127
  }
379
128
 
380
- const looksLikePath =
381
- isString(targetParam) &&
382
- (targetParam.includes("/") ||
383
- targetParam.includes("\\") ||
384
- targetParam.startsWith(".") ||
385
- (!/\s/.test(targetParam) && path.extname(targetParam).length > 0));
386
-
387
- if (looksLikePath) {
129
+ if (looksLikePath(targetParam)) {
388
130
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
389
- let text = await vfs.read(targetPath);
390
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
391
- const lines = text.split("\n");
392
- const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
393
- const startIndex = offset - 1;
394
- const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
395
- text = lines.slice(startIndex, startIndex + limit).join("\n");
396
- }
397
- return textResult(text, { path: targetPath });
398
- }
399
-
400
- let isExistingFile = false;
401
- let targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
402
- const overlay = vfs.getOverlay(targetPath);
403
- if (overlay !== undefined || vfs.cache.has(targetPath)) {
404
- isExistingFile = true;
405
- } else {
406
- try {
407
- const st = await fs.stat(targetPath);
408
- isExistingFile = !st.isDirectory();
409
- } catch (err) {
410
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
411
- }
131
+ const text = await vfs.read(targetPath);
132
+ return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
412
133
  }
413
134
 
414
- if (isExistingFile) {
415
- let text = await vfs.read(targetPath);
416
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
417
- const lines = text.split("\n");
418
- const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
419
- const startIndex = offset - 1;
420
- const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
421
- text = lines.slice(startIndex, startIndex + limit).join("\n");
422
- }
423
- return textResult(text, { path: targetPath });
135
+ const existing = await probeExistingFile(cwd, targetParam, vfs);
136
+ if (existing) {
137
+ const text = await vfs.read(existing);
138
+ return textResult(sliceLines(text, params?.offset, params?.limit), { path: existing });
424
139
  }
425
140
 
426
141
  if (isString(targetParam) && targetParam.trim()) {
@@ -435,17 +150,9 @@ function createNativeAdapters(getCwd, vfs, config) {
435
150
  } catch {}
436
151
  }
437
152
 
438
- targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
439
-
440
- let text = await vfs.read(targetPath);
441
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
442
- const lines = text.split("\n");
443
- const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
444
- const startIndex = offset - 1;
445
- const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
446
- text = lines.slice(startIndex, startIndex + limit).join("\n");
447
- }
448
- return textResult(text, { path: targetPath });
153
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
154
+ const text = await vfs.read(targetPath);
155
+ return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
449
156
  }
450
157
 
451
158
  return {
@@ -469,11 +176,7 @@ function createNativeAdapters(getCwd, vfs, config) {
469
176
  const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
470
177
  if (signal?.aborted) throw new Error("aborted");
471
178
 
472
- const isPatchMode =
473
- isString(params?.patch) ||
474
- (params?.newText === undefined && isString(params?.oldText) && (params.oldText.includes("@@ -") || params.oldText.startsWith("---")));
475
-
476
- if (isPatchMode) {
179
+ if (patchModeOf(params)) {
477
180
  const patchContent = params.patch || params.oldText;
478
181
  const original = await vfs.read(target);
479
182
  const { resultText, hunkCount } = applyPatchToText(original, patchContent);
@@ -491,31 +194,8 @@ function createNativeAdapters(getCwd, vfs, config) {
491
194
  const requestedEdits = Array.isArray(params?.edits)
492
195
  ? params.edits
493
196
  : [{ oldText: params?.oldText, newText: params?.newText }];
494
- if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
495
-
496
197
  const content = await vfs.read(target);
497
- const matches = requestedEdits.map((replacement) => {
498
- if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
499
- throw new Error("edit requires non-empty oldText");
500
- }
501
- if (!isString(replacement?.newText)) throw new Error("edit requires newText");
502
- const index = content.indexOf(replacement.oldText);
503
- if (index < 0) throw new Error(`edit target not found in ${target}`);
504
- if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
505
- throw new Error(`edit target is not unique in ${target}`);
506
- }
507
- return { ...replacement, index, end: index + replacement.oldText.length };
508
- });
509
- matches.sort((a, b) => a.index - b.index);
510
- for (let i = 1; i < matches.length; i++) {
511
- if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
512
- }
513
-
514
- let updated = content;
515
- for (let i = matches.length - 1; i >= 0; i--) {
516
- const match = matches[i];
517
- updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
518
- }
198
+ const { updated, matches } = applyReplacements(target, content, requestedEdits);
519
199
  const { speculative } = await vfs.write(target, updated);
520
200
  const diff =
521
201
  matches.length === 1
@@ -607,7 +287,8 @@ function createNativeAdapters(getCwd, vfs, config) {
607
287
  } finally {
608
288
  vfs.invalidateCache();
609
289
  }
610
- const text = [res.stdout, res.stderr].filter(Boolean).join("\n");
290
+ const { stdout, stderr } = res;
291
+ const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
611
292
  return {
612
293
  content: [{ type: "text", text }],
613
294
  details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -670,17 +351,7 @@ function createNativeAdapters(getCwd, vfs, config) {
670
351
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
671
352
  const lines = [];
672
353
  for (const entry of entries) {
673
- const isDir = entry.isDirectory();
674
- const isSym = entry.isSymbolicLink();
675
- const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
676
- let size = 0;
677
- try {
678
- if (!isDir && !isSym) {
679
- const st = await fs.stat(path.join(dirPath, entry.name));
680
- size = st.size;
681
- }
682
- } catch {}
683
- lines.push(`${entry.name}${isDir ? "/" : ""} (${typeLabel}${size ? `, ${size} bytes` : ""})`);
354
+ lines.push(await formatLsEntry(dirPath, entry));
684
355
  }
685
356
  return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
686
357
  },
@@ -722,6 +393,8 @@ export function createHostBridge({ pi, config, getCwd }) {
722
393
  function resetCallBudget() {
723
394
  callCount = 0;
724
395
  trace = [];
396
+ // Files may change between programs (editor, git); never serve a stale run.
397
+ vfs.invalidateCache();
725
398
  }
726
399
 
727
400
  function getTrace() {
@@ -771,15 +444,19 @@ export function createHostBridge({ pi, config, getCwd }) {
771
444
  } catch {}
772
445
  }
773
446
 
774
- async function invokeRaw(name, args) {
447
+ function checkCallBudget(name) {
775
448
  const maxCalls = config.maxBridgeCalls ?? 256;
776
449
  callCount += 1;
777
450
  if (callCount > maxCalls) {
778
- throw new Error(`supernova host call budget exceeded (${maxCalls})`);
451
+ throw new Error(
452
+ `host call budget exceeded (${maxCalls} calls per program): batch with read([paths]) or nova.callMany, or split the work across programs`,
453
+ );
779
454
  }
780
455
  if (activeSignal?.aborted) throw new Error("aborted");
781
456
  if (!isString(name) || !name) throw new Error("tool name required");
457
+ }
782
458
 
459
+ function assertCallableTarget(name) {
783
460
  // Never re-enter supernova or other excluded composition tools via the bridge.
784
461
  const excluded = new Set(config.excludeTools || []);
785
462
  if (name === "supernova" || excluded.has(name)) {
@@ -787,6 +464,30 @@ export function createHostBridge({ pi, config, getCwd }) {
787
464
  `nova.call("${name}") is blocked (excluded / non-reentrant). Use nova.search/describe for discovery, or call a concrete host tool.`,
788
465
  );
789
466
  }
467
+ }
468
+
469
+ async function writeFallbackDiff(name, args) {
470
+ if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
471
+ const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
472
+ let previous = "";
473
+ try {
474
+ previous = await vfs.read(target);
475
+ } catch (error) {
476
+ if (error?.code !== "ENOENT") throw error;
477
+ }
478
+ return buildWriteDiff(target, previous, args.content);
479
+ }
480
+
481
+ function completeRecord(record, res, fallbackDiff) {
482
+ const diff = resultDiff(res) || fallbackDiff;
483
+ finishRecord(record, res);
484
+ if (diff && record.ok) record.diff = diff;
485
+ notifyCall(record);
486
+ }
487
+
488
+ async function invokeRaw(name, args) {
489
+ checkCallBudget(name);
490
+ assertCallableTarget(name);
790
491
 
791
492
  const record = { name, args: args || {}, time: Date.now() };
792
493
  trace.push(record);
@@ -795,46 +496,36 @@ export function createHostBridge({ pi, config, getCwd }) {
795
496
  try {
796
497
  const exec = executors.get(name);
797
498
  if (exec) {
798
- let fallbackDiff;
799
- if (name === "write" && isString(args?.path) && isString(args?.content)) {
800
- const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
801
- let previous = "";
802
- try {
803
- previous = await vfs.read(target);
804
- } catch (error) {
805
- if (error?.code !== "ENOENT") throw error;
806
- }
807
- fallbackDiff = buildWriteDiff(target, previous, args.content);
808
- }
499
+ const fallbackDiff = await writeFallbackDiff(name, args);
809
500
  if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
810
501
  const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
811
- const diff = resultDiff(res) || fallbackDiff;
812
- record.ok = res?.isError !== true && res?.details?.ok !== false;
813
- if (diff && record.ok) record.diff = diff;
814
- notifyCall(record);
502
+ completeRecord(record, res, fallbackDiff);
815
503
  return res;
816
504
  }
817
505
 
818
506
  const native = natives[name];
819
507
  if (native) {
820
508
  const res = await native(args || {}, activeSignal);
821
- const diff = resultDiff(res);
822
- record.ok = res?.isError !== true && res?.details?.ok !== false;
823
- if (diff && record.ok) record.diff = diff;
824
- notifyCall(record);
509
+ completeRecord(record, res);
825
510
  return res;
826
511
  }
827
512
 
828
- throw new Error(
829
- `no executor for tool "${name}" (not captured via registerTool and no native adapter). Use nova.describe to inspect; ensure pi-supernova loads before other extensions, or call a core adapter: ${Object.keys(natives).join(", ")}`,
830
- );
513
+ throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
831
514
  } catch (error) {
832
515
  record.ok = false;
516
+ record.ms = Date.now() - record.time;
833
517
  notifyCall(record);
834
518
  throw error;
835
519
  }
836
520
  }
837
521
 
522
+ function finishRecord(record, res) {
523
+ record.ms = Date.now() - record.time;
524
+ record.ok = res?.isError !== true && res?.details?.ok !== false;
525
+ const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
526
+ if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
527
+ }
528
+
838
529
  async function call(name, args) {
839
530
  if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
840
531
  const raw = await invokeRaw(name, args);