pi-supernova 0.0.11 → 0.1.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.
package/host-bridge.js CHANGED
@@ -1,7 +1,7 @@
1
1
 
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { packageHostResult } from "./bottleneck.js";
4
+ import { packageHostResult, hostResultFailed } from "./bottleneck.js";
5
5
  import { isString, isNumber, isFunction, isObject } from "./decode.js";
6
6
  import { isMutatingTool, runParallelWave } from "./parallel.js";
7
7
  import { unknownToolMessage } from "./catalog.js";
@@ -9,10 +9,15 @@ import { extractStructuralSurface } from "./surface.js";
9
9
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
10
  import { executeSnap } from "./snap.js";
11
11
  import { selectEvidence } from "./evidence.js";
12
- import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
12
+ import { WorkspaceIndex } from "./repo-index.js";
13
+ import { outlineFile } from "./outline.js";
14
+ import { SeenLedger } from "./ledger.js";
15
+ import { quickCheck } from "./check.js";
16
+ import { declaredName } from "./repo-index.js";
13
17
  import { CausalVfs } from "./vfs.js";
14
18
  import { applyPatchToText } from "./patch.js";
15
- import { resolveWorkspacePath, runCommand, clearPathCache } from "./workspace.js";
19
+ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "./workspace.js";
20
+ import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs } from "./search.js";
16
21
 
17
22
  function textResult(text, details) {
18
23
  return {
@@ -63,15 +68,6 @@ async function probeExistingFile(cwd, targetParam, vfs) {
63
68
  }
64
69
  }
65
70
 
66
- function patchModeOf(params) {
67
- return (
68
- isString(params?.patch) ||
69
- (params?.newText === undefined &&
70
- isString(params?.oldText) &&
71
- (params.oldText.includes("@@ -") || params.oldText.startsWith("---")))
72
- );
73
- }
74
-
75
71
  function applyReplacements(target, content, requestedEdits) {
76
72
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
77
73
  const matches = requestedEdits.map((replacement) => {
@@ -83,7 +79,7 @@ function applyReplacements(target, content, requestedEdits) {
83
79
  if (index < 0) {
84
80
  throw new Error(`edit target not found in ${target}: oldText must match the file byte-for-byte (read() it first; check whitespace and quotes)`);
85
81
  }
86
- if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
82
+ if (content.indexOf(replacement.oldText, index + 1) >= 0) {
87
83
  throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
88
84
  }
89
85
  return { ...replacement, index, end: index + replacement.oldText.length };
@@ -115,84 +111,27 @@ async function formatLsEntry(dirPath, entry) {
115
111
  return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
116
112
  }
117
113
 
118
- function rgGrepArgs(pattern, params, searchPath) {
119
- const args = ["--line-number", "--no-heading", "--color", "never"];
120
- if (params?.caseSensitive !== true) args.push("--ignore-case");
121
- if (params?.glob) args.push("--glob", String(params.glob));
122
- args.push("--", pattern, searchPath);
123
- return args;
124
- }
125
-
126
- /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
127
- async function listWithTools(searchDir, pattern, cwd, signal) {
128
- const args = ["--files"];
129
- if (pattern) args.push("-g", pattern);
130
- const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
131
- if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
132
- const findArgs = [searchDir];
133
- if (pattern) findArgs.push("-name", pattern);
134
- const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
135
- return textResult(findRes.stdout, { via: "find" });
136
- }
137
-
138
- /** rg-compatible grep served from the index; null when the pattern or tree needs real rg. */
139
- async function grepIndexed(index, pattern, params, searchPath, cwd) {
140
- let regex;
141
- try {
142
- regex = new RegExp(pattern, params?.caseSensitive === true ? "" : "i");
143
- } catch {
144
- return null;
145
- }
146
- let files = await index.files(searchPath);
147
- if (!index.canScan(files)) return null;
148
- if (params?.glob) {
149
- const matcher = globToRegExp(String(params.glob));
150
- files = files.filter((f) => matcher.test(path.relative(cwd, f).split(path.sep).join("/")));
151
- }
152
- const rows = index.grep(files, regex, cwd);
153
- return rows.length ? rows.join("\n") + "\n" : "";
154
- }
155
-
156
- /** rg --files [-g pattern] served from the index; null when the tree is too large. */
157
- async function listIndexed(index, root, cwd, pattern) {
158
- const files = await index.files(root);
159
- if (!index.canScan(files)) return null;
160
- const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
161
- if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
162
- let matcher;
163
- try {
164
- matcher = globToRegExp(pattern);
165
- } catch {
166
- return null;
167
- }
168
- const hits = rel.filter((f) => matcher.test(f));
169
- return hits.length ? hits.join("\n") + "\n" : "";
170
- }
171
-
172
- function createNativeAdapters(getCwd, vfs, config, index) {
114
+ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
173
115
  async function readAdapter(params, signal) {
116
+ signal?.throwIfAborted();
174
117
  const cwd = getCwd();
175
118
  const targetParam = params?.path ?? params?.target;
176
119
 
177
120
  if (Array.isArray(targetParam)) {
178
121
  const results = await Promise.all(
179
- targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
122
+ targetParam.map((p) => readAdapter({ ...params, path: p }, signal)),
180
123
  );
181
124
  const items = results.map((r) => r.content[0].text);
182
- return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
125
+ return textResult("", { count: results.length, batch: true, items });
183
126
  }
184
127
 
185
128
  if (looksLikePath(targetParam)) {
186
129
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
187
- const text = await vfs.read(targetPath);
188
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
130
+ return readFile(targetPath, params);
189
131
  }
190
132
 
191
133
  const existing = await probeExistingFile(cwd, targetParam, vfs);
192
- if (existing) {
193
- const text = await vfs.read(existing);
194
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: existing });
195
- }
134
+ if (existing) return readFile(existing, params);
196
135
 
197
136
  if (isString(targetParam) && targetParam.trim()) {
198
137
  try {
@@ -208,8 +147,133 @@ function createNativeAdapters(getCwd, vfs, config, index) {
208
147
  }
209
148
 
210
149
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
211
- const text = await vfs.read(targetPath);
212
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
150
+ return readFile(targetPath, params);
151
+ }
152
+
153
+ /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
154
+ async function readFile(targetPath, params) {
155
+ const cwd = getCwd();
156
+ const rel = relativeSlash(cwd, targetPath);
157
+ const text = await vfs.read(targetPath);
158
+ index.touch(rel);
159
+ if (isString(params?.about)) {
160
+ const pending = vfs.getOverlay(targetPath);
161
+ const entry = pending === undefined ? index.entry(targetPath) : WorkspaceIndex.fromText(targetPath, pending);
162
+ const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
163
+ if (outline) {
164
+ recordOutlineOrigins(rel, outline.text);
165
+ return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
166
+ }
167
+ }
168
+ const explicit = isNumber(params?.offset) || isNumber(params?.limit);
169
+ const firstLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
170
+ const sliced = sliceLines(text, params?.offset, params?.limit);
171
+ ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
172
+ return textResult(sliced, { path: targetPath });
173
+ }
174
+
175
+ /**
176
+ * The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
177
+ * lines with numbers (so no verification re-read), a quick structural check, and every other
178
+ * place a changed declaration is referenced (so callers are not forgotten).
179
+ */
180
+ async function editSummary(cwd, target, original, updated, diff) {
181
+ const rel = relativeSlash(cwd, target);
182
+ const newLines = updated.split("\n");
183
+ const added = diff.lines.filter((l) => l.type === "add");
184
+ const first = added.length ? Math.max(1, added[0].lineNum - 2) : 1;
185
+ const last = added.length ? Math.min(newLines.length, added[added.length - 1].lineNum + 2) : Math.min(newLines.length, first + 6);
186
+ const window = [];
187
+ for (let l = first; l <= last && window.length < 40; l++) window.push(String(l).padStart(5) + " " + newLines[l - 1]);
188
+ ledger.recordOrigin(rel, first, newLines.slice(first - 1, first - 1 + window.length));
189
+ let out = `edited ${rel}:${first}–${first + window.length - 1}\n${window.join("\n")}`;
190
+ const check = quickCheck(updated, path.extname(target));
191
+ if (check && !check.ok) out += `\ncheck: ${check.message}`;
192
+ const refs = await changedDeclarationRefs(cwd, target, original, updated, diff);
193
+ if (refs) out += `\n${refs}`;
194
+ return out;
195
+ }
196
+
197
+ async function changedDeclarationRefs(cwd, target, original, updated, diff) {
198
+ // Diff rows carry the replaced fragments; declarations live on whole file lines.
199
+ const oldLines = original.split("\n");
200
+ const newLines = updated.split("\n");
201
+ const names = new Set();
202
+ for (const l of diff.lines) {
203
+ if (l.type === "context") continue;
204
+ const name = declaredName((l.type === "remove" ? oldLines : newLines)[l.lineNum - 1] ?? "");
205
+ if (name) names.add(name);
206
+ }
207
+ if (names.size === 0) return "";
208
+ const find = await referenceFinder(cwd, target);
209
+ const parts = [];
210
+ for (const name of [...names].slice(0, 3)) {
211
+ const refs = find(name).filter((r) => !r.startsWith(relativeSlash(cwd, target) + ":"));
212
+ if (refs.length) parts.push(`${name} also referenced in ${refs.slice(0, 6).join(", ")}${refs.length > 6 ? " (+" + (refs.length - 6) + ")" : ""}`);
213
+ }
214
+ return parts.join("\n");
215
+ }
216
+
217
+ const SOURCE_REF = /((?:[\w.@-]+\/)*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
218
+
219
+ /** Source window (±2 lines, ► on the cited line) for one path:line, or null when it is outside the workspace/index. */
220
+ function sourceWindow(cwd, file, lineNo) {
221
+ const candidate = path.resolve(cwd, file);
222
+ if (!candidate.startsWith(path.resolve(cwd) + path.sep)) return null;
223
+ const entry = index.entry(candidate);
224
+ if (!entry) return null;
225
+ const { raw } = WorkspaceIndex.linesOf(entry);
226
+ if (lineNo < 1 || lineNo > raw.length) return null;
227
+ const rel = relativeSlash(cwd, candidate);
228
+ const start = Math.max(1, lineNo - 2);
229
+ const rows = [];
230
+ for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
231
+ ledger.recordOrigin(rel, start, rows);
232
+ return rel + ":" + lineNo + "\n" + rows.join("\n");
233
+ }
234
+
235
+ /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
236
+ function sourceForReferences(cwd, output) {
237
+ const seen = new Set();
238
+ const blocks = [];
239
+ for (const m of output.matchAll(SOURCE_REF)) {
240
+ const key = m[1] + ":" + m[2];
241
+ if (seen.has(key)) continue;
242
+ seen.add(key);
243
+ const block = sourceWindow(cwd, m[1], Number(m[2]));
244
+ if (block) blocks.push(block);
245
+ if (blocks.length >= 4) break;
246
+ }
247
+ return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
248
+ }
249
+
250
+ function outlineOptions(params, references) {
251
+ const options = { references };
252
+ if (params?.maxChars) options.maxChars = params.maxChars;
253
+ return options;
254
+ }
255
+
256
+ /** Outline lines carry their own line numbers (" 330 text"); provenance follows them. */
257
+ function recordOutlineOrigins(rel, outlineText) {
258
+ for (const line of outlineText.split("\n")) {
259
+ const m = /^\s*(\d+) (.*)$/.exec(line);
260
+ if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
261
+ }
262
+ }
263
+
264
+ /** Where else a name appears (declaration line excluded), for outlines and edit results. */
265
+ async function referenceFinder(cwd, targetPath) {
266
+ const files = await index.files(cwd);
267
+ if (!index.canScan(files)) return () => [];
268
+ return (name, excludeLine) => {
269
+ if (!name || name.length < 3) return [];
270
+ const escaped = name.replace(/[$]/g, (c) => "\\" + c);
271
+ const regex = new RegExp("\\b" + escaped + "\\b");
272
+ return index
273
+ .grepRows(files, regex, cwd)
274
+ .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
275
+ .map((r) => r.rel + ":" + r.line);
276
+ };
213
277
  }
214
278
 
215
279
  return {
@@ -218,12 +282,14 @@ function createNativeAdapters(getCwd, vfs, config, index) {
218
282
  const cwd = getCwd();
219
283
  const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
220
284
  if (signal?.aborted) throw new Error("aborted");
221
- const content = String(params?.content ?? "");
285
+ if (!isString(params?.content)) throw new Error("write requires string content");
286
+ const content = params.content;
222
287
  let prevText = "";
223
288
  try {
224
289
  prevText = await vfs.read(target);
225
290
  } catch {}
226
291
  const { speculative } = await vfs.write(target, content);
292
+ index.touch(relativeSlash(cwd, target));
227
293
  const diff = buildWriteDiff(target, prevText, content);
228
294
  const tag = speculative ? " (speculative)" : "";
229
295
  return textResult(`wrote ${target}${tag}`, { path: target, speculative, diff });
@@ -233,33 +299,19 @@ function createNativeAdapters(getCwd, vfs, config, index) {
233
299
  const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
234
300
  if (signal?.aborted) throw new Error("aborted");
235
301
 
236
- if (patchModeOf(params)) {
237
- const patchContent = params.patch || params.oldText;
238
- const original = await vfs.read(target);
239
- const { resultText, hunkCount } = applyPatchToText(original, patchContent);
240
- const { speculative } = await vfs.write(target, resultText);
241
- const diff = buildPatchDiff(target, patchContent);
242
- const tag = speculative ? " (speculative)" : "";
243
- return textResult(`applied ${hunkCount} hunk(s) to ${target}${tag}`, {
244
- path: target,
245
- hunks: hunkCount,
246
- speculative,
247
- diff,
248
- });
249
- }
250
-
251
302
  const requestedEdits = Array.isArray(params?.edits)
252
303
  ? params.edits
253
304
  : [{ oldText: params?.oldText, newText: params?.newText }];
254
305
  const content = await vfs.read(target);
255
306
  const { updated, matches } = applyReplacements(target, content, requestedEdits);
256
307
  const { speculative } = await vfs.write(target, updated);
308
+ index.touch(relativeSlash(cwd, target));
257
309
  const diff =
258
310
  matches.length === 1
259
311
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
260
312
  : buildMultiEditDiff(target, content, matches);
261
- const tag = speculative ? " (speculative)" : "";
262
- return textResult(`edited ${target}${tag}`, { path: target, speculative, diff });
313
+ const summary = await editSummary(cwd, target, content, updated, diff);
314
+ return textResult(summary, { path: target, speculative, diff });
263
315
  },
264
316
  async apply_patch(params, signal) {
265
317
  const cwd = getCwd();
@@ -316,7 +368,8 @@ function createNativeAdapters(getCwd, vfs, config, index) {
316
368
  const options = {};
317
369
  if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
318
370
  if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
319
- const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), options });
371
+ const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
372
+ for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
320
373
  return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
321
374
  },
322
375
  async surface(params, signal) {
@@ -352,10 +405,11 @@ function createNativeAdapters(getCwd, vfs, config, index) {
352
405
  index.invalidate();
353
406
  }
354
407
  const { stdout, stderr } = res;
355
- const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
408
+ let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
409
+ if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
356
410
  return {
357
411
  content: [{ type: "text", text }],
358
- details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
412
+ details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
359
413
  isError: res.exitCode !== 0,
360
414
  };
361
415
  },
@@ -366,6 +420,7 @@ function createNativeAdapters(getCwd, vfs, config, index) {
366
420
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
367
421
  const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
368
422
  if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
423
+ // Large tree: real rg keeps its own output format.
369
424
  const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
370
425
  if (res.exitCode !== 0 && res.exitCode !== 1) {
371
426
  throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
@@ -376,6 +431,8 @@ function createNativeAdapters(getCwd, vfs, config, index) {
376
431
  const cwd = getCwd();
377
432
  const pattern = String(params?.pattern || "");
378
433
  if (!pattern) throw new Error("glob requires pattern");
434
+ const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
435
+ if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
379
436
  const indexed = await listIndexed(index, cwd, cwd, pattern);
380
437
  if (indexed !== null) return textResult(indexed, { via: "index" });
381
438
  const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
@@ -398,6 +455,8 @@ function createNativeAdapters(getCwd, vfs, config, index) {
398
455
  const pattern = params?.pattern || params?.glob;
399
456
  if (signal?.aborted) throw new Error("aborted");
400
457
  const globPattern = pattern ? String(pattern) : null;
458
+ const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
459
+ if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
401
460
  const indexed = await listIndexed(index, searchDir, cwd, globPattern);
402
461
  if (indexed !== null) return textResult(indexed, { via: "index" });
403
462
  return listWithTools(searchDir, globPattern, cwd, signal);
@@ -416,18 +475,24 @@ function createNativeAdapters(getCwd, vfs, config, index) {
416
475
  };
417
476
  }
418
477
 
419
- export function createHostBridge({ pi, config, getCwd }) {
420
- const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
478
+ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
479
+ const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
480
+ const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
421
481
  const vfs = new CausalVfs(() => index.invalidate());
422
- const executors = new Map();
423
- const natives = createNativeAdapters(getCwd, vfs, config, index);
482
+ const executors = registry?.executors ?? new Map();
483
+ const definitions = registry?.definitions ?? new Map();
484
+ const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
485
+ let closed = false;
486
+ const natives = createNativeAdapters(getCwd, vfs, config, index, ledger);
424
487
  let callCount = 0;
425
488
  let activeCtx = null;
489
+ let hostSession = null;
490
+ let boundSessionId;
426
491
  let activeSignal = undefined;
427
492
  let trace = [];
428
493
  let callListener = null;
429
494
 
430
- if (pi && isFunction(pi.registerTool)) {
495
+ if (!registry && pi && isFunction(pi.registerTool)) {
431
496
  const original = pi.registerTool.bind(pi);
432
497
  const excluded = new Set(config.excludeTools || []);
433
498
  pi.registerTool = (tool) => {
@@ -439,6 +504,7 @@ export function createHostBridge({ pi, config, getCwd }) {
439
504
  !excluded.has(tool.name)
440
505
  ) {
441
506
  executors.set(tool.name, tool.execute.bind(tool));
507
+ definitions.set(tool.name, tool);
442
508
  }
443
509
  return original(tool);
444
510
  };
@@ -446,10 +512,52 @@ export function createHostBridge({ pi, config, getCwd }) {
446
512
 
447
513
  function bindCallContext(ctx, signal) {
448
514
  activeCtx = ctx || null;
515
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
516
+ boundSessionId = sessionId;
517
+ const registry = pi?.pi?.AgentRegistry?.global?.();
518
+ hostSession = sessionId && registry?.list
519
+ ? registry.list().map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
520
+ : null;
449
521
  activeSignal = signal;
522
+ vfs.signal = signal;
523
+ }
524
+
525
+ function hostTool(name) {
526
+ if (!hostSession) return undefined;
527
+ const metadata = definitions.get(name);
528
+ // Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
529
+ if (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
530
+ return hostSession.getToolForEvalBridge?.(name);
531
+ }
532
+
533
+ function isCallable(name) {
534
+ if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
535
+ if (hostSession) {
536
+ if (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId) return false;
537
+ if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
538
+ return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
539
+ }
540
+ if (definitions.has(name) && isFunction(pi?.getActiveTools) && !pi.getActiveTools().includes(name)) return false;
541
+ return executors.has(name) || Object.hasOwn(natives, name);
542
+ }
543
+
544
+ function refreshTools() {
545
+ const tools = pi?.getAllTools?.() ?? [];
546
+ for (const tool of tools) {
547
+ if (!isString(tool?.name)) continue;
548
+ definitions.set(tool.name, { ...definitions.get(tool.name), ...tool });
549
+ if (!hostSession && isFunction(tool.execute)) executors.set(tool.name, tool.execute.bind(tool));
550
+ }
551
+ return [...definitions.values()].filter(tool => isCallable(tool.name));
552
+ }
553
+
554
+ function externalNames() {
555
+ return [...definitions.keys()].filter(name => !!hostTool(name) || executors.has(name));
450
556
  }
451
557
 
452
558
  function resetCallBudget() {
559
+ closed = false;
560
+ vfs.closed = false;
453
561
  callCount = 0;
454
562
  trace = [];
455
563
  // Files may change between programs (editor, git); never serve a stale run.
@@ -465,10 +573,6 @@ export function createHostBridge({ pi, config, getCwd }) {
465
573
  callListener = isFunction(fn) ? fn : null;
466
574
  }
467
575
 
468
- function hasExecutor(name) {
469
- return executors.has(name) || Object.hasOwn(natives, name);
470
- }
471
-
472
576
  function beginSpeculation() {
473
577
  return vfs.begin();
474
578
  }
@@ -481,10 +585,6 @@ export function createHostBridge({ pi, config, getCwd }) {
481
585
  return vfs.rollback();
482
586
  }
483
587
 
484
- function clearVfsCache() {
485
- vfs.clear();
486
- }
487
-
488
588
  function resultDiff(response) {
489
589
  let details = response?.details;
490
590
  if (isString(details)) {
@@ -505,6 +605,7 @@ export function createHostBridge({ pi, config, getCwd }) {
505
605
  }
506
606
 
507
607
  function checkCallBudget(name) {
608
+ if (closed) throw new Error("program is already complete");
508
609
  const maxCalls = config.maxBridgeCalls ?? 256;
509
610
  callCount += 1;
510
611
  if (callCount > maxCalls) {
@@ -547,23 +648,35 @@ export function createHostBridge({ pi, config, getCwd }) {
547
648
 
548
649
  async function invokeRaw(name, args) {
549
650
  checkCallBudget(name);
651
+ const callId = ++sharedRegistry.callSeq;
550
652
  assertCallableTarget(name);
653
+ if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
551
654
 
552
655
  const record = { name, args: args || {}, time: Date.now() };
553
656
  trace.push(record);
554
657
  notifyCall(record);
555
658
 
556
659
  try {
557
- const exec = executors.get(name);
660
+ const delegated = hostTool(name);
661
+ const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
558
662
  if (exec) {
559
663
  const fallbackDiff = await writeFallbackDiff(name, args);
560
- if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
561
- const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
562
- completeRecord(record, res, fallbackDiff);
563
- return res;
664
+ const mutating = isMutatingTool(name, config, args, definitions.get(name));
665
+ if (mutating) await vfs.prepareExternalMutation(name);
666
+ if (activeSignal?.aborted || closed) throw new Error("aborted");
667
+ if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
668
+ try {
669
+ const res = await exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, delegated
670
+ ? { ...activeCtx, settings: hostSession.settings, toolNames: hostSession.getEvalBridgeToolNames(), autoApprove: false }
671
+ : activeCtx);
672
+ completeRecord(record, res, fallbackDiff);
673
+ return res;
674
+ } finally {
675
+ if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); }
676
+ }
564
677
  }
565
678
 
566
- const native = natives[name];
679
+ const native = Object.hasOwn(natives, name) ? natives[name] : undefined;
567
680
  if (native) {
568
681
  const res = await native(args || {}, activeSignal);
569
682
  completeRecord(record, res);
@@ -582,7 +695,7 @@ export function createHostBridge({ pi, config, getCwd }) {
582
695
 
583
696
  function finishRecord(record, res) {
584
697
  record.ms = Date.now() - record.time;
585
- record.ok = res?.isError !== true && res?.details?.ok !== false;
698
+ record.ok = !hostResultFailed(res);
586
699
  const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
587
700
  if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
588
701
  }
@@ -594,14 +707,16 @@ export function createHostBridge({ pi, config, getCwd }) {
594
707
  }
595
708
 
596
709
  async function callMany(calls) {
597
- const list = Array.isArray(calls) ? calls : [];
710
+ if (!Array.isArray(calls)) throw new TypeError("nova.callMany requires an array");
711
+ const list = calls;
712
+ if (list.some(item => !isString(item?.name) || !item.name)) throw new TypeError("nova.callMany entries require a tool name");
598
713
  const thunks = list.map((item) => {
599
714
  const n = item?.name;
600
715
  const a = item?.args;
601
716
  return () => call(n, a);
602
717
  });
603
718
  const names = list.map((item) => item?.name).filter((n) => isString(n));
604
- const wave = await runParallelWave(thunks, { names }, { mode: "auto", config });
719
+ const wave = await runParallelWave(thunks, { names, calls: list, definitions: names.map(name => definitions.get(name)) }, { mode: "auto", config });
605
720
  // Return a results array that also carries .mode/.reason, and is directly
606
721
  // iterable so `for (const r of await nova.callMany([...]))` works.
607
722
  const results = Array.isArray(wave.results) ? wave.results.slice() : [];
@@ -615,20 +730,27 @@ export function createHostBridge({ pi, config, getCwd }) {
615
730
 
616
731
  return {
617
732
  executors,
733
+ definitions,
618
734
  natives,
735
+ refreshTools,
736
+ isCallable,
737
+ externalNames,
738
+ supportsBatchRead: () => !hostTool("read") && !executors.has("read"),
739
+ fork(options) {
740
+ return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork() });
741
+ },
742
+ close() { closed = true; vfs.closed = true; },
619
743
  bindCallContext,
620
744
  resetCallBudget,
621
745
  getTrace,
622
746
  setCallListener,
623
- hasExecutor,
624
747
  beginSpeculation,
625
748
  commitSpeculation,
626
749
  rollbackSpeculation,
627
- clearVfsCache,
628
750
  getVfsCacheSize: () => vfs.getCacheSize(),
629
751
  getOverlayDepth: () => vfs.getOverlayDepth(),
630
752
  call,
631
753
  callMany,
632
- isMutating: (name) => isMutatingTool(name, config),
754
+ ledger,
633
755
  };
634
756
  }