pi-supernova 0.0.1

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 ADDED
@@ -0,0 +1,822 @@
1
+
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { packageHostResult } from "./bottleneck.js";
6
+ import { isString, isNumber, isFunction } from "./decode.js";
7
+ import { isMutatingTool, runParallelWave } from "./parallel.js";
8
+ import { extractStructuralSurface } from "./surface.js";
9
+ import { buildEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
+ import { executeSnap } from "./snap.js";
11
+
12
+ function textResult(text, details) {
13
+ return {
14
+ content: [{ type: "text", text: String(text ?? "") }],
15
+ details: details || {},
16
+ };
17
+ }
18
+
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;
27
+ }
28
+
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.startsWith("..") || 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.startsWith("..") || 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
+ });
123
+ }
124
+
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
+ };
135
+ }
136
+
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 @@)");
154
+ }
155
+ return hunks;
156
+ }
157
+
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;
174
+ }
175
+
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`);
205
+ }
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`);
211
+ }
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
+ async read(target) {
246
+ const overlay = this.getOverlay(target);
247
+ if (overlay !== undefined) return overlay;
248
+
249
+ const cached = this.cache.get(target);
250
+ if (cached !== undefined) return cached;
251
+
252
+ try {
253
+ const text = await fs.readFile(target, "utf8");
254
+ this.setCache(target, text);
255
+ return text;
256
+ } catch (err) {
257
+ if (err.code === "EISDIR") {
258
+ throw new Error(`read path is a directory, not a file: ${target}`);
259
+ }
260
+ throw err;
261
+ }
262
+ }
263
+
264
+ async write(target, content) {
265
+ if (this.overlays.length > 0) {
266
+ this.overlays[this.overlays.length - 1].set(target, content);
267
+ return { speculative: true };
268
+ }
269
+
270
+ try {
271
+ const stat = await fs.stat(target);
272
+ if (stat.isDirectory()) {
273
+ throw new Error(`cannot write to a directory: ${target}`);
274
+ }
275
+ } catch (err) {
276
+ if (err.code !== "ENOENT") throw err;
277
+ }
278
+
279
+ await fs.mkdir(path.dirname(target), { recursive: true });
280
+ await fs.writeFile(target, content, "utf8");
281
+ this.setCache(target, content);
282
+ return { speculative: false };
283
+ }
284
+
285
+ begin() {
286
+ this.overlays.push(new Map());
287
+ return this.overlays.length;
288
+ }
289
+
290
+ async commit() {
291
+ if (this.overlays.length === 0) return { committed: 0, depth: 0 };
292
+ const top = this.overlays.pop();
293
+ if (this.overlays.length > 0) {
294
+ const parent = this.overlays[this.overlays.length - 1];
295
+ for (const [k, v] of top.entries()) parent.set(k, v);
296
+ return { committed: top.size, depth: this.overlays.length };
297
+ }
298
+ for (const [filePath, fileContent] of top.entries()) {
299
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
300
+ await fs.writeFile(filePath, fileContent, "utf8");
301
+ this.setCache(filePath, fileContent);
302
+ }
303
+ return { committed: top.size, depth: 0 };
304
+ }
305
+
306
+ rollback() {
307
+ if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
308
+ const top = this.overlays.pop();
309
+ return { rolledBack: top.size, depth: this.overlays.length };
310
+ }
311
+
312
+ async prepareExternalMutation(name) {
313
+ if (this.overlays.length > 1) {
314
+ throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
315
+ }
316
+ if (this.overlays.length === 0) return false;
317
+ const pending = this.overlays[0];
318
+ for (const [filePath, fileContent] of pending.entries()) {
319
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
320
+ await fs.writeFile(filePath, fileContent, "utf8");
321
+ this.setCache(filePath, fileContent);
322
+ }
323
+ this.overlays[0] = new Map();
324
+ return pending.size > 0;
325
+ }
326
+
327
+ invalidateCache() {
328
+ this.cache.clear();
329
+ }
330
+
331
+ clear() {
332
+ this.invalidateCache();
333
+ this.overlays.length = 0;
334
+ }
335
+
336
+ getCacheSize() {
337
+ return this.cache.size;
338
+ }
339
+
340
+ getOverlayDepth() {
341
+ return this.overlays.length;
342
+ }
343
+ }
344
+
345
+ function createNativeAdapters(getCwd, vfs, config) {
346
+ async function readAdapter(params, signal) {
347
+ const cwd = getCwd();
348
+ const targetParam = params?.path ?? params?.target;
349
+
350
+ if (Array.isArray(targetParam)) {
351
+ const results = await Promise.all(
352
+ targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
353
+ );
354
+ return textResult(results.map((r) => r.value).join("\n---\n"), {
355
+ count: results.length,
356
+ batch: true,
357
+ items: results.map((r) => r.value),
358
+ });
359
+ }
360
+
361
+ const looksLikePath =
362
+ isString(targetParam) &&
363
+ (targetParam.includes("/") ||
364
+ targetParam.includes("\\") ||
365
+ targetParam.startsWith(".") ||
366
+ (!/\s/.test(targetParam) && path.extname(targetParam).length > 0));
367
+
368
+ if (looksLikePath) {
369
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
370
+ let text = await vfs.read(targetPath);
371
+ if (isNumber(params?.offset) || isNumber(params?.limit)) {
372
+ const lines = text.split("\n");
373
+ const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
374
+ const startIndex = offset - 1;
375
+ const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
376
+ text = lines.slice(startIndex, startIndex + limit).join("\n");
377
+ }
378
+ return textResult(text, { path: targetPath });
379
+ }
380
+
381
+ let isExistingFile = false;
382
+ let targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
383
+ const overlay = vfs.getOverlay(targetPath);
384
+ if (overlay !== undefined || vfs.cache.has(targetPath)) {
385
+ isExistingFile = true;
386
+ } else {
387
+ try {
388
+ const st = await fs.stat(targetPath);
389
+ isExistingFile = !st.isDirectory();
390
+ } catch (err) {
391
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
392
+ }
393
+ }
394
+
395
+ if (isExistingFile) {
396
+ let text = await vfs.read(targetPath);
397
+ if (isNumber(params?.offset) || isNumber(params?.limit)) {
398
+ const lines = text.split("\n");
399
+ const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
400
+ const startIndex = offset - 1;
401
+ const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
402
+ text = lines.slice(startIndex, startIndex + limit).join("\n");
403
+ }
404
+ return textResult(text, { path: targetPath });
405
+ }
406
+
407
+ if (isString(targetParam) && targetParam.trim()) {
408
+ try {
409
+ const snapRes = await executeSnap({
410
+ query: targetParam,
411
+ searchDir: cwd,
412
+ vfs,
413
+ runCommand: (argv, opts) => runCommand(argv, { cwd, signal, ...opts }),
414
+ });
415
+ return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
416
+ } catch {}
417
+ }
418
+
419
+ targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
420
+
421
+ let text = await vfs.read(targetPath);
422
+ if (isNumber(params?.offset) || isNumber(params?.limit)) {
423
+ const lines = text.split("\n");
424
+ const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
425
+ const startIndex = offset - 1;
426
+ const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
427
+ text = lines.slice(startIndex, startIndex + limit).join("\n");
428
+ }
429
+ return textResult(text, { path: targetPath });
430
+ }
431
+
432
+ return {
433
+ read: readAdapter,
434
+ async write(params, signal) {
435
+ const cwd = getCwd();
436
+ const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
437
+ if (signal?.aborted) throw new Error("aborted");
438
+ const content = String(params?.content ?? "");
439
+ let prevText = "";
440
+ try {
441
+ prevText = await vfs.read(target);
442
+ } catch {}
443
+ const { speculative } = await vfs.write(target, content);
444
+ const diff = buildWriteDiff(target, prevText, content);
445
+ const tag = speculative ? " (speculative)" : "";
446
+ return textResult(`wrote ${target}${tag}`, { path: target, speculative, diff });
447
+ },
448
+ async edit(params, signal) {
449
+ const cwd = getCwd();
450
+ const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
451
+ if (signal?.aborted) throw new Error("aborted");
452
+
453
+ const isPatchMode =
454
+ isString(params?.patch) ||
455
+ (params?.newText === undefined && isString(params?.oldText) && (params.oldText.includes("@@ -") || params.oldText.startsWith("---")));
456
+
457
+ if (isPatchMode) {
458
+ const patchContent = params.patch || params.oldText;
459
+ const original = await vfs.read(target);
460
+ const { resultText, hunkCount } = applyPatchToText(original, patchContent);
461
+ const { speculative } = await vfs.write(target, resultText);
462
+ const diff = buildPatchDiff(target, patchContent);
463
+ const tag = speculative ? " (speculative)" : "";
464
+ return textResult(`applied ${hunkCount} hunk(s) to ${target}${tag}`, {
465
+ path: target,
466
+ hunks: hunkCount,
467
+ speculative,
468
+ diff,
469
+ });
470
+ }
471
+
472
+ const requestedEdits = Array.isArray(params?.edits)
473
+ ? params.edits
474
+ : [{ oldText: params?.oldText, newText: params?.newText }];
475
+ if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
476
+
477
+ const content = await vfs.read(target);
478
+ const matches = requestedEdits.map((replacement) => {
479
+ if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
480
+ throw new Error("edit requires non-empty oldText");
481
+ }
482
+ if (!isString(replacement?.newText)) throw new Error("edit requires newText");
483
+ const index = content.indexOf(replacement.oldText);
484
+ if (index < 0) throw new Error(`edit target not found in ${target}`);
485
+ if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
486
+ throw new Error(`edit target is not unique in ${target}`);
487
+ }
488
+ return { ...replacement, index, end: index + replacement.oldText.length };
489
+ });
490
+ matches.sort((a, b) => a.index - b.index);
491
+ for (let i = 1; i < matches.length; i++) {
492
+ if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
493
+ }
494
+
495
+ let updated = content;
496
+ for (let i = matches.length - 1; i >= 0; i--) {
497
+ const match = matches[i];
498
+ updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
499
+ }
500
+ const { speculative } = await vfs.write(target, updated);
501
+ const diff =
502
+ matches.length === 1
503
+ ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
504
+ : { ...buildWriteDiff(target, content, updated), op: "edit" };
505
+ const tag = speculative ? " (speculative)" : "";
506
+ return textResult(`edited ${target}${tag}`, { path: target, speculative, diff });
507
+ },
508
+ async apply_patch(params, signal) {
509
+ const cwd = getCwd();
510
+ let inputPath = params?.path;
511
+ if (!inputPath && isString(params?.patch)) {
512
+ const headerMatch = /^\+\+\+\s+[ab]\/(.+)$/m.exec(params.patch) || /^---\s+[ab]\/(.+)$/m.exec(params.patch);
513
+ if (headerMatch) inputPath = headerMatch[1].trim();
514
+ }
515
+ const target = await resolveWorkspacePath(cwd, inputPath, "apply_patch", false);
516
+ if (!isString(params?.patch) || !params.patch.trim()) {
517
+ throw new Error("apply_patch requires patch");
518
+ }
519
+ if (signal?.aborted) throw new Error("aborted");
520
+
521
+ const original = await vfs.read(target);
522
+ const { resultText, hunkCount } = applyPatchToText(original, params.patch);
523
+ const { speculative } = await vfs.write(target, resultText);
524
+ const diff = buildPatchDiff(target, params.patch);
525
+ const tag = speculative ? " (speculative)" : "";
526
+ return textResult(`applied ${hunkCount} hunk(s) to ${target}${tag}`, {
527
+ path: target,
528
+ hunks: hunkCount,
529
+ speculative,
530
+ diff,
531
+ });
532
+ },
533
+ async snap(params, signal) {
534
+ const cwd = getCwd();
535
+ if (!isString(params?.query) || !params.query.trim()) {
536
+ throw new Error("snap requires query");
537
+ }
538
+ if (signal?.aborted) throw new Error("aborted");
539
+ const snapTarget = params?.path ? await resolveWorkspacePath(cwd, params.path, "snap", true) : cwd;
540
+ const res = await executeSnap({
541
+ query: params.query,
542
+ searchDir: snapTarget,
543
+ vfs: {
544
+ read: async (candidate) => {
545
+ // Jail each snap candidate (symlink files must not escape the workspace).
546
+ const jailed = await resolveWorkspacePath(cwd, candidate, "snap", false);
547
+ return vfs.read(jailed);
548
+ },
549
+ },
550
+ runCommand: (argv, opts) => runCommand(argv, { cwd: snapTarget, signal, ...opts }),
551
+ });
552
+ return textResult(JSON.stringify(res, null, 2), res);
553
+ },
554
+ async surface(params, signal) {
555
+ const cwd = getCwd();
556
+ const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
557
+ if (signal?.aborted) throw new Error("aborted");
558
+ const text = await vfs.read(target);
559
+ const ext = path.extname(target);
560
+ const outline = extractStructuralSurface(text, ext);
561
+ return textResult(JSON.stringify(outline, null, 2), { path: target, count: outline.items.length });
562
+ },
563
+ async bash(params, signal) {
564
+ const cwd = getCwd();
565
+ const command = String(params?.command ?? "").trim();
566
+ if (!command) throw new Error("bash requires command");
567
+ const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
568
+
569
+ const hasShellMeta = /[|><&;*$()'"]/.test(command) || command.includes("`");
570
+ const argv = hasShellMeta ? ["bash", "-c", command] : command.split(/\s+/);
571
+
572
+ const transactionBarrier = await vfs.prepareExternalMutation("bash");
573
+ let res;
574
+ try {
575
+ res = await runCommand(argv, {
576
+ cwd: targetCwd,
577
+ timeoutMs: params?.timeoutMs,
578
+ signal,
579
+ maxOutputChars: config.maxCallResultChars,
580
+ });
581
+ } finally {
582
+ vfs.invalidateCache();
583
+ }
584
+ const text = [res.stdout, res.stderr].filter(Boolean).join("\n");
585
+ return {
586
+ content: [{ type: "text", text }],
587
+ details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
588
+ isError: res.exitCode !== 0,
589
+ };
590
+ },
591
+ async grep(params, signal) {
592
+ const cwd = getCwd();
593
+ const pattern = String(params?.pattern || "");
594
+ if (!pattern) throw new Error("grep requires pattern");
595
+ const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
596
+ const args = ["--line-number", "--no-heading", "--color", "never"];
597
+ if (params?.caseSensitive !== true) args.push("--ignore-case");
598
+ if (params?.glob) args.push("--glob", String(params.glob));
599
+ args.push("--", pattern, searchPath);
600
+ const res = await runCommand(["rg", ...args], { cwd, timeoutMs: 30_000, signal });
601
+ if (res.exitCode !== 0 && res.exitCode !== 1) {
602
+ throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
603
+ }
604
+ return textResult(res.stdout, { exitCode: res.exitCode });
605
+ },
606
+ async glob(params, signal) {
607
+ const cwd = getCwd();
608
+ const pattern = String(params?.pattern || "");
609
+ if (!pattern) throw new Error("glob requires pattern");
610
+ const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
611
+ () => null,
612
+ );
613
+ if (rg && (rg.exitCode === 0 || rg.exitCode === 1)) {
614
+ return textResult(rg.stdout, { via: "rg" });
615
+ }
616
+ const findPattern = pattern.startsWith("./") ? pattern : `./${pattern}`;
617
+ const fallback = await runCommand(["find", ".", "-type", "f", "-path", findPattern], {
618
+ cwd,
619
+ timeoutMs: 30_000,
620
+ signal,
621
+ });
622
+ return textResult(fallback.stdout, { via: "find" });
623
+ },
624
+ async find(params, signal) {
625
+ const cwd = getCwd();
626
+ const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
627
+ const pattern = params?.pattern || params?.glob;
628
+ if (signal?.aborted) throw new Error("aborted");
629
+ const args = ["--files"];
630
+ if (pattern) args.push("-g", String(pattern));
631
+ const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
632
+ if (res && (res.exitCode === 0 || res.exitCode === 1)) {
633
+ return textResult(res.stdout, { via: "rg" });
634
+ }
635
+ const findArgs = [searchDir];
636
+ if (pattern) findArgs.push("-name", String(pattern));
637
+ const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
638
+ return textResult(findRes.stdout, { via: "find" });
639
+ },
640
+ async ls(params, signal) {
641
+ const cwd = getCwd();
642
+ const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
643
+ if (signal?.aborted) throw new Error("aborted");
644
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
645
+ const lines = [];
646
+ for (const entry of entries) {
647
+ const isDir = entry.isDirectory();
648
+ const isSym = entry.isSymbolicLink();
649
+ const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
650
+ let size = 0;
651
+ try {
652
+ if (!isDir && !isSym) {
653
+ const st = await fs.stat(path.join(dirPath, entry.name));
654
+ size = st.size;
655
+ }
656
+ } catch {}
657
+ lines.push(`${entry.name}${isDir ? "/" : ""} (${typeLabel}${size ? `, ${size} bytes` : ""})`);
658
+ }
659
+ return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
660
+ },
661
+ };
662
+ }
663
+
664
+ export function createHostBridge({ pi, config, getCwd }) {
665
+ const vfs = new CausalVfs();
666
+ const executors = new Map();
667
+ const natives = createNativeAdapters(getCwd, vfs, config);
668
+ let callCount = 0;
669
+ let activeCtx = null;
670
+ let activeSignal = undefined;
671
+ let trace = [];
672
+ let callListener = null;
673
+
674
+ if (pi && isFunction(pi.registerTool)) {
675
+ const original = pi.registerTool.bind(pi);
676
+ const excluded = new Set(config.excludeTools || []);
677
+ pi.registerTool = (tool) => {
678
+ if (
679
+ tool &&
680
+ isString(tool.name) &&
681
+ isFunction(tool.execute) &&
682
+ tool.name !== "supernova" &&
683
+ !excluded.has(tool.name)
684
+ ) {
685
+ executors.set(tool.name, tool.execute.bind(tool));
686
+ }
687
+ return original(tool);
688
+ };
689
+ }
690
+
691
+ function bindCallContext(ctx, signal) {
692
+ activeCtx = ctx || null;
693
+ activeSignal = signal;
694
+ }
695
+
696
+ function resetCallBudget() {
697
+ callCount = 0;
698
+ trace = [];
699
+ }
700
+
701
+ function getTrace() {
702
+ return [...trace];
703
+ }
704
+
705
+ function setCallListener(fn) {
706
+ callListener = isFunction(fn) ? fn : null;
707
+ }
708
+
709
+ function hasExecutor(name) {
710
+ return executors.has(name) || Object.hasOwn(natives, name);
711
+ }
712
+
713
+ function beginSpeculation() {
714
+ return vfs.begin();
715
+ }
716
+
717
+ async function commitSpeculation() {
718
+ return await vfs.commit();
719
+ }
720
+
721
+ function rollbackSpeculation() {
722
+ return vfs.rollback();
723
+ }
724
+
725
+ function clearVfsCache() {
726
+ vfs.clear();
727
+ }
728
+
729
+ async function invokeRaw(name, args) {
730
+ const maxCalls = config.maxBridgeCalls ?? 256;
731
+ callCount += 1;
732
+ if (callCount > maxCalls) {
733
+ throw new Error(`supernova host call budget exceeded (${maxCalls})`);
734
+ }
735
+ if (activeSignal?.aborted) throw new Error("aborted");
736
+ if (!isString(name) || !name) throw new Error("tool name required");
737
+
738
+ // Never re-enter supernova or other excluded composition tools via the bridge.
739
+ const excluded = new Set(config.excludeTools || []);
740
+ if (name === "supernova" || excluded.has(name)) {
741
+ throw new Error(
742
+ `nova.call("${name}") is blocked (excluded / non-reentrant). Use nova.search/describe for discovery, or call a concrete host tool.`,
743
+ );
744
+ }
745
+
746
+ const record = { name, args: args || {}, time: Date.now() };
747
+ trace.push(record);
748
+
749
+ const exec = executors.get(name);
750
+ if (exec) {
751
+ if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
752
+ const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
753
+ if (callListener) {
754
+ try {
755
+ callListener(record, [...trace]);
756
+ } catch {}
757
+ }
758
+ return res;
759
+ }
760
+
761
+ const native = natives[name];
762
+ if (native) {
763
+ const res = await native(args || {}, activeSignal);
764
+ if (res?.details?.diff) record.diff = res.details.diff;
765
+ if (callListener) {
766
+ try {
767
+ callListener(record, [...trace]);
768
+ } catch {}
769
+ }
770
+ return res;
771
+ }
772
+
773
+ throw new Error(
774
+ `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(", ")}`,
775
+ );
776
+ }
777
+
778
+ async function call(name, args) {
779
+ if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
780
+ const raw = await invokeRaw(name, args);
781
+ return packageHostResult(raw, config);
782
+ }
783
+
784
+ async function callMany(calls) {
785
+ const list = Array.isArray(calls) ? calls : [];
786
+ const thunks = list.map((item) => {
787
+ const n = item?.name;
788
+ const a = item?.args;
789
+ return () => call(n, a);
790
+ });
791
+ const names = list.map((item) => item?.name).filter((n) => isString(n));
792
+ const wave = await runParallelWave(thunks, { names }, { mode: "auto", config });
793
+ // Return a results array that also carries .mode/.reason, and is directly
794
+ // iterable so `for (const r of await nova.callMany([...]))` works.
795
+ const results = Array.isArray(wave.results) ? wave.results.slice() : [];
796
+ Object.defineProperties(results, {
797
+ mode: { value: wave.mode, enumerable: false },
798
+ reason: { value: wave.reason, enumerable: false },
799
+ results: { value: results, enumerable: false },
800
+ });
801
+ return results;
802
+ }
803
+
804
+ return {
805
+ executors,
806
+ natives,
807
+ bindCallContext,
808
+ resetCallBudget,
809
+ getTrace,
810
+ setCallListener,
811
+ hasExecutor,
812
+ beginSpeculation,
813
+ commitSpeculation,
814
+ rollbackSpeculation,
815
+ clearVfsCache,
816
+ getVfsCacheSize: () => vfs.getCacheSize(),
817
+ getOverlayDepth: () => vfs.getOverlayDepth(),
818
+ call,
819
+ callMany,
820
+ isMutating: (name) => isMutatingTool(name, config),
821
+ };
822
+ }