pi-supernova 0.0.6 → 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 {
@@ -16,338 +19,98 @@ function textResult(text, details) {
16
19
  };
17
20
  }
18
21
 
19
- let cachedCwd = null;
20
- let cachedResolvedCwd = null;
21
-
22
- function getResolvedCwd(cwd) {
23
- if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
24
- cachedCwd = cwd;
25
- cachedResolvedCwd = path.resolve(cwd);
26
- return cachedResolvedCwd;
22
+ /** Unwrap a single matching quote pair around the whole string (`'git status'`). */
23
+ function unwrapIfFullyQuoted(s) {
24
+ if (s.length < 2) return s;
25
+ const q = s[0];
26
+ if (q !== "'" && q !== '"') return s;
27
+ if (s[s.length - 1] !== q) return s;
28
+ const inner = s.slice(1, -1);
29
+ if (inner.includes(q)) return s;
30
+ return inner;
27
31
  }
28
32
 
29
- async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
30
- if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
31
- throw new Error(`${opName} requires path`);
32
- }
33
- const resolvedCwd = getResolvedCwd(cwd);
34
- const target = path.resolve(resolvedCwd, inputPath.trim());
35
- const rel = path.relative(resolvedCwd, target);
36
- if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
37
- throw new Error(`${opName} path escapes workspace`);
38
- }
39
- if (!allowRoot && target === resolvedCwd) {
40
- throw new Error(`${opName} path cannot be the workspace root directory`);
41
- }
42
-
43
- const realRoot = await fs.realpath(resolvedCwd);
44
- let probe = target;
45
- while (true) {
46
- try {
47
- probe = await fs.realpath(probe);
48
- break;
49
- } catch (err) {
50
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
51
- const parent = path.dirname(probe);
52
- if (parent === probe) throw err;
53
- probe = parent;
54
- }
55
- }
56
- const realRel = path.relative(realRoot, probe);
57
- if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
58
- throw new Error(`${opName} path escapes workspace through symlink`);
59
- }
60
- return target;
61
- }
62
-
63
- async function runCommand(argv, options = {}) {
64
- const cwd = options.cwd || process.cwd();
65
- const timeoutMs = options.timeoutMs ?? 60_000;
66
- const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
67
- return await new Promise((resolve, reject) => {
68
- const child = spawn(argv[0], argv.slice(1), {
69
- cwd,
70
- env: process.env,
71
- stdio: ["ignore", "pipe", "pipe"],
72
- });
73
- let stdout = "";
74
- let stderr = "";
75
- let settled = false;
76
- let outputTruncated = false;
77
- let onAbort;
78
-
79
- const cleanup = () => {
80
- clearTimeout(timer);
81
- if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
82
- };
83
- const fail = (err) => {
84
- if (settled) return;
85
- settled = true;
86
- cleanup();
87
- reject(err);
88
- };
89
- const append = (current, chunk) => {
90
- const remaining = Math.max(0, maxOutputChars - current.length);
91
- if (chunk.length > remaining) outputTruncated = true;
92
- return remaining > 0 ? current + chunk.slice(0, remaining) : current;
93
- };
94
- const timer = setTimeout(() => {
95
- child.kill("SIGTERM");
96
- fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
97
- }, timeoutMs);
98
-
99
- child.stdout.setEncoding("utf8");
100
- child.stderr.setEncoding("utf8");
101
- child.stdout.on("data", (chunk) => {
102
- stdout = append(stdout, chunk);
103
- });
104
- child.stderr.on("data", (chunk) => {
105
- stderr = append(stderr, chunk);
106
- });
107
- child.on("error", fail);
108
- child.on("close", (code) => {
109
- if (settled) return;
110
- settled = true;
111
- cleanup();
112
- resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
113
- });
114
- if (options.signal) {
115
- onAbort = () => {
116
- child.kill("SIGTERM");
117
- fail(new Error("aborted"));
118
- };
119
- if (options.signal.aborted) onAbort();
120
- else options.signal.addEventListener("abort", onAbort, { once: true });
121
- }
122
- });
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");
123
39
  }
124
40
 
125
- function parseHunkHeader(line) {
126
- const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
127
- if (!match) return null;
128
- return {
129
- oldStart: parseInt(match[1], 10),
130
- oldLength: match[2] !== undefined ? parseInt(match[2], 10) : 1,
131
- newStart: parseInt(match[3], 10),
132
- newLength: match[4] !== undefined ? parseInt(match[4], 10) : 1,
133
- lines: [],
134
- };
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
+ );
135
49
  }
136
50
 
137
- export function parsePatchHunks(patchText) {
138
- const patchLines = patchText.replace(/\r\n/g, "\n").split("\n");
139
- const hunks = [];
140
- let current = null;
141
-
142
- for (const line of patchLines) {
143
- const header = parseHunkHeader(line);
144
- if (header) {
145
- if (current) hunks.push(current);
146
- current = header;
147
- } else if (current && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
148
- current.lines.push(line);
149
- }
150
- }
151
- if (current) hunks.push(current);
152
- if (hunks.length === 0) {
153
- throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
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;
154
61
  }
155
- return hunks;
156
62
  }
157
63
 
158
- function findHunkMatch(fileLines, expectedOld, nominal) {
159
- const matchAt = (idx) => {
160
- if (idx < 0 || idx + expectedOld.length > fileLines.length) return false;
161
- for (let j = 0; j < expectedOld.length; j++) {
162
- if (fileLines[idx + j] !== expectedOld[j]) return false;
163
- }
164
- return true;
165
- };
166
-
167
- if (matchAt(nominal)) return nominal;
168
- const maxDelta = Math.max(fileLines.length, 100);
169
- for (let delta = 1; delta <= maxDelta; delta++) {
170
- if (matchAt(nominal + delta)) return nominal + delta;
171
- if (matchAt(nominal - delta)) return nominal - delta;
172
- }
173
- return -1;
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
+ );
174
71
  }
175
72
 
176
- export function applyPatchToText(originalText, patchText) {
177
- if (!isString(patchText) || !patchText.trim()) {
178
- throw new Error("apply_patch requires non-empty patch");
179
- }
180
-
181
- const hunks = parsePatchHunks(patchText);
182
- let fileLines = originalText.replace(/\r\n/g, "\n").split("\n");
183
- const hasTrailingNewline = originalText.endsWith("\n");
184
- let offsetShift = 0;
185
-
186
- for (let h = 0; h < hunks.length; h++) {
187
- const hunk = hunks[h];
188
- const expectedOld = [];
189
- const newLines = [];
190
-
191
- for (const hLine of hunk.lines) {
192
- if (hLine.startsWith("-")) {
193
- expectedOld.push(hLine.slice(1));
194
- } else if (hLine.startsWith("+")) {
195
- newLines.push(hLine.slice(1));
196
- } else {
197
- const val = hLine.startsWith(" ") ? hLine.slice(1) : "";
198
- expectedOld.push(val);
199
- newLines.push(val);
200
- }
201
- }
202
-
203
- if (expectedOld.length !== hunk.oldLength || newLines.length !== hunk.newLength) {
204
- throw new Error(`patch hunk ${h + 1} length does not match its header`);
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");
205
78
  }
206
-
207
- const nominal = Math.max(0, hunk.oldStart - 1 + offsetShift);
208
- const matchIdx = findHunkMatch(fileLines, expectedOld, nominal);
209
- if (matchIdx === -1) {
210
- throw new Error(`patch hunk ${h + 1} rejected at line ${hunk.oldStart}: context did not match`);
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)`);
211
83
  }
212
-
213
- fileLines.splice(matchIdx, expectedOld.length, ...newLines);
214
- offsetShift += (matchIdx - nominal) + (newLines.length - expectedOld.length);
215
- }
216
-
217
- let resultText = fileLines.join("\n");
218
- if (hasTrailingNewline && !resultText.endsWith("\n")) resultText += "\n";
219
- return { resultText, hunkCount: hunks.length };
220
- }
221
-
222
- const VFS_CACHE_MAX = 1024;
223
-
224
- class CausalVfs {
225
- constructor() {
226
- this.cache = new Map();
227
- this.overlays = [];
228
- }
229
-
230
- setCache(target, content) {
231
- if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
232
- const oldest = this.cache.keys().next().value;
233
- if (oldest !== undefined) this.cache.delete(oldest);
234
- }
235
- this.cache.set(target, content);
236
- }
237
-
238
- getOverlay(target) {
239
- for (let i = this.overlays.length - 1; i >= 0; i--) {
240
- if (this.overlays[i].has(target)) return this.overlays[i].get(target);
241
- }
242
- return undefined;
243
- }
244
-
245
- getOverlayPaths() {
246
- const paths = new Set();
247
- for (const overlay of this.overlays) {
248
- for (const target of overlay.keys()) paths.add(target);
249
- }
250
- return [...paths];
251
- }
252
-
253
- async read(target) {
254
- const overlay = this.getOverlay(target);
255
- if (overlay !== undefined) return overlay;
256
-
257
- const cached = this.cache.get(target);
258
- if (cached !== undefined) return cached;
259
-
260
- try {
261
- const text = await fs.readFile(target, "utf8");
262
- this.setCache(target, text);
263
- return text;
264
- } catch (err) {
265
- if (err.code === "EISDIR") {
266
- throw new Error(`read path is a directory, not a file: ${target}`);
267
- }
268
- throw err;
269
- }
270
- }
271
-
272
- async write(target, content) {
273
- if (this.overlays.length > 0) {
274
- this.overlays[this.overlays.length - 1].set(target, content);
275
- return { speculative: true };
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},…]`);
276
86
  }
277
-
278
- try {
279
- const stat = await fs.stat(target);
280
- if (stat.isDirectory()) {
281
- throw new Error(`cannot write to a directory: ${target}`);
282
- }
283
- } catch (err) {
284
- if (err.code !== "ENOENT") throw err;
285
- }
286
-
287
- await fs.mkdir(path.dirname(target), { recursive: true });
288
- await fs.writeFile(target, content, "utf8");
289
- this.setCache(target, content);
290
- return { speculative: false };
291
- }
292
-
293
- begin() {
294
- this.overlays.push(new Map());
295
- return this.overlays.length;
296
- }
297
-
298
- async commit() {
299
- if (this.overlays.length === 0) return { committed: 0, depth: 0 };
300
- const top = this.overlays.pop();
301
- if (this.overlays.length > 0) {
302
- const parent = this.overlays[this.overlays.length - 1];
303
- for (const [k, v] of top.entries()) parent.set(k, v);
304
- return { committed: top.size, depth: this.overlays.length };
305
- }
306
- for (const [filePath, fileContent] of top.entries()) {
307
- await fs.mkdir(path.dirname(filePath), { recursive: true });
308
- await fs.writeFile(filePath, fileContent, "utf8");
309
- this.setCache(filePath, fileContent);
310
- }
311
- 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}`);
312
92
  }
313
-
314
- rollback() {
315
- if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
316
- const top = this.overlays.pop();
317
- 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);
318
97
  }
98
+ return { updated, matches };
99
+ }
319
100
 
320
- async prepareExternalMutation(name) {
321
- if (this.overlays.length > 1) {
322
- throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
323
- }
324
- if (this.overlays.length === 0) return false;
325
- const pending = this.overlays[0];
326
- for (const [filePath, fileContent] of pending.entries()) {
327
- await fs.mkdir(path.dirname(filePath), { recursive: true });
328
- await fs.writeFile(filePath, fileContent, "utf8");
329
- this.setCache(filePath, fileContent);
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;
330
110
  }
331
- this.overlays[0] = new Map();
332
- return pending.size > 0;
333
- }
334
-
335
- invalidateCache() {
336
- this.cache.clear();
337
- }
338
-
339
- clear() {
340
- this.invalidateCache();
341
- this.overlays.length = 0;
342
- }
343
-
344
- getCacheSize() {
345
- return this.cache.size;
346
- }
347
-
348
- getOverlayDepth() {
349
- return this.overlays.length;
350
- }
111
+ } catch {}
112
+ const sizeSuffix = size ? `, ${size} bytes` : "";
113
+ return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
351
114
  }
352
115
 
353
116
  function createNativeAdapters(getCwd, vfs, config) {
@@ -359,57 +122,20 @@ function createNativeAdapters(getCwd, vfs, config) {
359
122
  const results = await Promise.all(
360
123
  targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
361
124
  );
362
- return textResult(results.map((r) => r.value).join("\n---\n"), {
363
- count: results.length,
364
- batch: true,
365
- items: results.map((r) => r.value),
366
- });
125
+ const items = results.map((r) => r.content[0].text);
126
+ return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
367
127
  }
368
128
 
369
- const looksLikePath =
370
- isString(targetParam) &&
371
- (targetParam.includes("/") ||
372
- targetParam.includes("\\") ||
373
- targetParam.startsWith(".") ||
374
- (!/\s/.test(targetParam) && path.extname(targetParam).length > 0));
375
-
376
- if (looksLikePath) {
129
+ if (looksLikePath(targetParam)) {
377
130
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
378
- let text = await vfs.read(targetPath);
379
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
380
- const lines = text.split("\n");
381
- const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
382
- const startIndex = offset - 1;
383
- const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
384
- text = lines.slice(startIndex, startIndex + limit).join("\n");
385
- }
386
- return textResult(text, { path: targetPath });
387
- }
388
-
389
- let isExistingFile = false;
390
- let targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
391
- const overlay = vfs.getOverlay(targetPath);
392
- if (overlay !== undefined || vfs.cache.has(targetPath)) {
393
- isExistingFile = true;
394
- } else {
395
- try {
396
- const st = await fs.stat(targetPath);
397
- isExistingFile = !st.isDirectory();
398
- } catch (err) {
399
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
400
- }
131
+ const text = await vfs.read(targetPath);
132
+ return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
401
133
  }
402
134
 
403
- if (isExistingFile) {
404
- let text = await vfs.read(targetPath);
405
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
406
- const lines = text.split("\n");
407
- const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
408
- const startIndex = offset - 1;
409
- const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
410
- text = lines.slice(startIndex, startIndex + limit).join("\n");
411
- }
412
- 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 });
413
139
  }
414
140
 
415
141
  if (isString(targetParam) && targetParam.trim()) {
@@ -424,17 +150,9 @@ function createNativeAdapters(getCwd, vfs, config) {
424
150
  } catch {}
425
151
  }
426
152
 
427
- targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
428
-
429
- let text = await vfs.read(targetPath);
430
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
431
- const lines = text.split("\n");
432
- const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
433
- const startIndex = offset - 1;
434
- const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
435
- text = lines.slice(startIndex, startIndex + limit).join("\n");
436
- }
437
- 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 });
438
156
  }
439
157
 
440
158
  return {
@@ -458,11 +176,7 @@ function createNativeAdapters(getCwd, vfs, config) {
458
176
  const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
459
177
  if (signal?.aborted) throw new Error("aborted");
460
178
 
461
- const isPatchMode =
462
- isString(params?.patch) ||
463
- (params?.newText === undefined && isString(params?.oldText) && (params.oldText.includes("@@ -") || params.oldText.startsWith("---")));
464
-
465
- if (isPatchMode) {
179
+ if (patchModeOf(params)) {
466
180
  const patchContent = params.patch || params.oldText;
467
181
  const original = await vfs.read(target);
468
182
  const { resultText, hunkCount } = applyPatchToText(original, patchContent);
@@ -480,31 +194,8 @@ function createNativeAdapters(getCwd, vfs, config) {
480
194
  const requestedEdits = Array.isArray(params?.edits)
481
195
  ? params.edits
482
196
  : [{ oldText: params?.oldText, newText: params?.newText }];
483
- if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
484
-
485
197
  const content = await vfs.read(target);
486
- const matches = requestedEdits.map((replacement) => {
487
- if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
488
- throw new Error("edit requires non-empty oldText");
489
- }
490
- if (!isString(replacement?.newText)) throw new Error("edit requires newText");
491
- const index = content.indexOf(replacement.oldText);
492
- if (index < 0) throw new Error(`edit target not found in ${target}`);
493
- if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
494
- throw new Error(`edit target is not unique in ${target}`);
495
- }
496
- return { ...replacement, index, end: index + replacement.oldText.length };
497
- });
498
- matches.sort((a, b) => a.index - b.index);
499
- for (let i = 1; i < matches.length; i++) {
500
- if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
501
- }
502
-
503
- let updated = content;
504
- for (let i = matches.length - 1; i >= 0; i--) {
505
- const match = matches[i];
506
- updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
507
- }
198
+ const { updated, matches } = applyReplacements(target, content, requestedEdits);
508
199
  const { speculative } = await vfs.write(target, updated);
509
200
  const diff =
510
201
  matches.length === 1
@@ -576,12 +267,13 @@ function createNativeAdapters(getCwd, vfs, config) {
576
267
  },
577
268
  async bash(params, signal) {
578
269
  const cwd = getCwd();
579
- const command = String(params?.command ?? "").trim();
270
+ const command = unwrapIfFullyQuoted(String(params?.command ?? "").trim());
580
271
  if (!command) throw new Error("bash requires command");
581
272
  const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
582
273
 
583
- const hasShellMeta = /[|><&;*$()'"]/.test(command) || command.includes("`");
584
- const argv = hasShellMeta ? ["bash", "-c", command] : command.split(/\s+/);
274
+ // Always shell. Splitting on spaces treated `git status` / quoted `exec("git status")`
275
+ // as a single binary name (`bash: git status: command not found`).
276
+ const argv = ["bash", "-c", command];
585
277
 
586
278
  const transactionBarrier = await vfs.prepareExternalMutation("bash");
587
279
  let res;
@@ -595,7 +287,8 @@ function createNativeAdapters(getCwd, vfs, config) {
595
287
  } finally {
596
288
  vfs.invalidateCache();
597
289
  }
598
- 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;
599
292
  return {
600
293
  content: [{ type: "text", text }],
601
294
  details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -658,17 +351,7 @@ function createNativeAdapters(getCwd, vfs, config) {
658
351
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
659
352
  const lines = [];
660
353
  for (const entry of entries) {
661
- const isDir = entry.isDirectory();
662
- const isSym = entry.isSymbolicLink();
663
- const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
664
- let size = 0;
665
- try {
666
- if (!isDir && !isSym) {
667
- const st = await fs.stat(path.join(dirPath, entry.name));
668
- size = st.size;
669
- }
670
- } catch {}
671
- lines.push(`${entry.name}${isDir ? "/" : ""} (${typeLabel}${size ? `, ${size} bytes` : ""})`);
354
+ lines.push(await formatLsEntry(dirPath, entry));
672
355
  }
673
356
  return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
674
357
  },
@@ -710,6 +393,8 @@ export function createHostBridge({ pi, config, getCwd }) {
710
393
  function resetCallBudget() {
711
394
  callCount = 0;
712
395
  trace = [];
396
+ // Files may change between programs (editor, git); never serve a stale run.
397
+ vfs.invalidateCache();
713
398
  }
714
399
 
715
400
  function getTrace() {
@@ -759,15 +444,19 @@ export function createHostBridge({ pi, config, getCwd }) {
759
444
  } catch {}
760
445
  }
761
446
 
762
- async function invokeRaw(name, args) {
447
+ function checkCallBudget(name) {
763
448
  const maxCalls = config.maxBridgeCalls ?? 256;
764
449
  callCount += 1;
765
450
  if (callCount > maxCalls) {
766
- 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
+ );
767
454
  }
768
455
  if (activeSignal?.aborted) throw new Error("aborted");
769
456
  if (!isString(name) || !name) throw new Error("tool name required");
457
+ }
770
458
 
459
+ function assertCallableTarget(name) {
771
460
  // Never re-enter supernova or other excluded composition tools via the bridge.
772
461
  const excluded = new Set(config.excludeTools || []);
773
462
  if (name === "supernova" || excluded.has(name)) {
@@ -775,6 +464,30 @@ export function createHostBridge({ pi, config, getCwd }) {
775
464
  `nova.call("${name}") is blocked (excluded / non-reentrant). Use nova.search/describe for discovery, or call a concrete host tool.`,
776
465
  );
777
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);
778
491
 
779
492
  const record = { name, args: args || {}, time: Date.now() };
780
493
  trace.push(record);
@@ -783,46 +496,36 @@ export function createHostBridge({ pi, config, getCwd }) {
783
496
  try {
784
497
  const exec = executors.get(name);
785
498
  if (exec) {
786
- let fallbackDiff;
787
- if (name === "write" && isString(args?.path) && isString(args?.content)) {
788
- const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
789
- let previous = "";
790
- try {
791
- previous = await vfs.read(target);
792
- } catch (error) {
793
- if (error?.code !== "ENOENT") throw error;
794
- }
795
- fallbackDiff = buildWriteDiff(target, previous, args.content);
796
- }
499
+ const fallbackDiff = await writeFallbackDiff(name, args);
797
500
  if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
798
501
  const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
799
- const diff = resultDiff(res) || fallbackDiff;
800
- record.ok = res?.isError !== true && res?.details?.ok !== false;
801
- if (diff && record.ok) record.diff = diff;
802
- notifyCall(record);
502
+ completeRecord(record, res, fallbackDiff);
803
503
  return res;
804
504
  }
805
505
 
806
506
  const native = natives[name];
807
507
  if (native) {
808
508
  const res = await native(args || {}, activeSignal);
809
- const diff = resultDiff(res);
810
- record.ok = res?.isError !== true && res?.details?.ok !== false;
811
- if (diff && record.ok) record.diff = diff;
812
- notifyCall(record);
509
+ completeRecord(record, res);
813
510
  return res;
814
511
  }
815
512
 
816
- throw new Error(
817
- `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(", ")}`,
818
- );
513
+ throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
819
514
  } catch (error) {
820
515
  record.ok = false;
516
+ record.ms = Date.now() - record.time;
821
517
  notifyCall(record);
822
518
  throw error;
823
519
  }
824
520
  }
825
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
+
826
529
  async function call(name, args) {
827
530
  if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
828
531
  const raw = await invokeRaw(name, args);