pi-supernova 0.0.15 → 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/guest-worker.js CHANGED
@@ -1,95 +1,15 @@
1
1
  import { parentPort } from "node:worker_threads";
2
2
  import { parallel as runParallel, pipeline as runPipeline } from "./parallel.js";
3
- import { isString, isFunction, isObject } from "./decode.js";
3
+ import { isString, isObject, toPlain } from "./decode.js";
4
+ import { truncateChars } from "./format.js";
4
5
 
5
6
  // Guest programs run here, off the host thread. The host can terminate() this
6
7
  // worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
7
8
  // code cannot take the harness down with it.
8
9
 
9
10
  const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
10
- const compiledCache = new Map();
11
- const COMPILED_CACHE_MAX = 256;
12
- const PARAMS = [
13
- "nova", "console", "parallel", "pipeline",
14
- "read", "write", "edit", "patch", "surface", "snap", "evidence", "bash", "exec", "speculate",
15
- ];
16
- // V8 and JSC both place the body on the line after the synthesized header.
11
+ const PARAMS = ["nova", "console", "parallel", "pipeline", "read", "write", "edit", "patch", "surface", "snap", "evidence", "bash", "exec", "speculate"];
17
12
  const BODY_LINE_OFFSET = 2;
18
-
19
- function skipLeadingComments(src) {
20
- let i = 0;
21
- for (;;) {
22
- while (/\s/.test(src[i])) i++;
23
- if (src.startsWith("//", i)) {
24
- const nl = src.indexOf("\n", i);
25
- if (nl < 0) return src.length;
26
- i = nl + 1;
27
- } else if (src.startsWith("/*", i)) {
28
- const end = src.indexOf("*/", i + 2);
29
- if (end < 0) return src.length;
30
- i = end + 2;
31
- } else {
32
- return i;
33
- }
34
- }
35
- }
36
-
37
- function skipString(src, j, quote) {
38
- for (j++; j < src.length && src[j] !== quote; j++) if (src[j] === "\\") j++;
39
- return j;
40
- }
41
-
42
- function skipBalancedParens(src, i) {
43
- let depth = 0;
44
- for (let j = i; j < src.length; j++) {
45
- const ch = src[j];
46
- if (ch === "(") {
47
- depth++;
48
- continue;
49
- }
50
- if (ch === ")") {
51
- depth--;
52
- if (depth === 0) return j + 1;
53
- continue;
54
- }
55
- if ("\"'\u0060".includes(ch)) j = skipString(src, j, ch);
56
- }
57
- return -1;
58
- }
59
-
60
- /** True when src (after comments) is a single arrow or function expression. */
61
- function isFunctionExpression(src) {
62
- let i = skipLeadingComments(src);
63
- const rest = src.slice(i);
64
- if (/^(async\s+)?function\b/.test(rest)) return true;
65
- const asyncMatch = /^async\s+/.exec(rest);
66
- if (asyncMatch) i += asyncMatch[0].length;
67
- if (/^[A-Za-z_$][\w$]*\s*=>/.test(src.slice(i))) return true;
68
- if (src[i] !== "(") return false;
69
- const after = skipBalancedParens(src, i);
70
- if (after < 0) return false;
71
- return /^\s*=>/.test(src.slice(after));
72
- }
73
-
74
- function wrapBody(code) {
75
- const trimmed = String(code).trim();
76
- if (!trimmed) throw new Error("code must be a non-empty string");
77
- if (isFunctionExpression(trimmed)) return "const __fn = (" + trimmed + ");\nreturn await __fn();";
78
- return trimmed;
79
- }
80
-
81
- function compile(code) {
82
- const body = wrapBody(code);
83
- let compiled = compiledCache.get(body);
84
- if (compiled) return compiled;
85
- compiled = new AsyncFunction(...PARAMS, body);
86
- if (compiledCache.size >= COMPILED_CACHE_MAX) {
87
- compiledCache.delete(compiledCache.keys().next().value);
88
- }
89
- compiledCache.set(body, compiled);
90
- return compiled;
91
- }
92
-
93
13
  /** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
94
14
  function guestLocation(err) {
95
15
  const stack = String(err?.stack);
@@ -100,64 +20,8 @@ function guestLocation(err) {
100
20
  return { line, col: Number(m[2]) };
101
21
  }
102
22
 
103
- const MAX_DEPTH = 64;
104
- const MAX_TYPED_ARRAY = 4096;
105
-
106
- function plainFromBinary(value) {
107
- const bytes = value.byteLength;
108
- if (value instanceof ArrayBuffer) value = new Uint8Array(value);
109
- if (value.length > MAX_TYPED_ARRAY) return "[" + value.constructor.name + " " + bytes + " bytes]";
110
- return Array.from(value, (x) => (typeof x === "bigint" ? x.toString() + "n" : x));
111
- }
112
-
113
- function plainFromMap(value, seen, depth) {
114
- const allStringKeys = [...value.keys()].every((k) => typeof k === "string");
115
- if (!allStringKeys) return [...value].map(([k, v]) => [toPlain(k, seen, depth + 1), toPlain(v, seen, depth + 1)]);
116
- const out = {};
117
- for (const [k, v] of value) out[k] = toPlain(v, seen, depth + 1);
118
- return out;
119
- }
120
-
121
- function plainFromCollection(value, seen, depth) {
122
- if (Array.isArray(value)) return value.map((x) => toPlain(x, seen, depth + 1));
123
- if (value instanceof Set) return [...value].map((x) => toPlain(x, seen, depth + 1));
124
- if (value instanceof Map) return plainFromMap(value, seen, depth);
125
- const out = {};
126
- for (const k of Object.keys(value)) out[k] = toPlain(value[k], seen, depth + 1);
127
- return out;
128
- }
129
-
130
- /** Convert any guest value to structured-clone-safe, JSON-shaped data. */
131
- function toPlain(value, seen = new Set(), depth = 0) {
132
- if (value === null || value === undefined) return value;
133
- const t = typeof value;
134
- if (t === "string" || t === "number" || t === "boolean") return value;
135
- if (t === "bigint") return value.toString() + "n";
136
- if (t === "function") return "[Function" + (value.name ? " " + value.name : "") + "]";
137
- if (t === "symbol") return value.toString();
138
- if (depth > MAX_DEPTH) return "[Depth]";
139
- if (seen.has(value)) return "[Circular]";
140
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
141
- if (value instanceof RegExp) return value.toString();
142
- if (value instanceof Error) {
143
- const out = { name: value.name, message: value.message };
144
- if (value.cause !== undefined) out.cause = toPlain(value.cause, seen, depth + 1);
145
- return out;
146
- }
147
- if (value instanceof Promise) return "[Promise]";
148
- if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return plainFromBinary(value);
149
- if (isFunction(value.toJSON)) return toPlain(value.toJSON(), seen, depth + 1);
150
- seen.add(value);
151
- try {
152
- return plainFromCollection(value, seen, depth);
153
- } finally {
154
- seen.delete(value);
155
- }
156
- }
157
-
158
- // ---- RPC to the host thread ----
159
-
160
23
  let activeRunId = 0;
24
+ let runActive = false;
161
25
  let rpcSeq = 0;
162
26
  const pendingRpc = new Map();
163
27
 
@@ -165,12 +29,13 @@ function post(msg) {
165
29
  parentPort.postMessage(msg);
166
30
  }
167
31
 
168
- function rpc(method, args) {
32
+ function callRpc(runId, method, args) {
33
+ if (!runActive || runId !== activeRunId) return Promise.reject(new Error("program is already complete"));
169
34
  return new Promise((resolve, reject) => {
170
35
  const id = ++rpcSeq;
171
36
  pendingRpc.set(id, { resolve, reject });
172
37
  try {
173
- post({ op: "rpc", id, runId: activeRunId, method, args });
38
+ post({ op: "rpc", id, runId, method, args });
174
39
  } catch (err) {
175
40
  pendingRpc.delete(id);
176
41
  reject(new Error("nova." + method + " arguments are not transferable: " + err?.message));
@@ -179,6 +44,7 @@ function rpc(method, args) {
179
44
  }
180
45
 
181
46
  function unwrapValue(res) {
47
+ if (res?.ok === false) throw new Error(String(res.value ?? res.error ?? "host tool failed"));
182
48
  if ("value" in Object(res)) return res.value;
183
49
  return res;
184
50
  }
@@ -201,7 +67,8 @@ function leanEnvelope(res) {
201
67
  return res;
202
68
  }
203
69
 
204
- function buildGuestApi(available) {
70
+ function buildGuestApi(available, batchRead, runId) {
71
+ const rpc = (method, args) => callRpc(runId, method, args);
205
72
  const availableSet = new Set(available);
206
73
  const nova = {
207
74
  search: (query, limit) => rpc("search", [query, limit]),
@@ -238,7 +105,9 @@ function buildGuestApi(available) {
238
105
  const readArgs = (p, a, b) => (isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
239
106
  const read = async (p, a, b) => {
240
107
  if (Array.isArray(p)) {
108
+ if (!batchRead) return Promise.all(p.map(item => read(item, a, b)));
241
109
  const res = await nova.call("read", readArgs(p, a, b));
110
+ unwrapValue(res);
242
111
  if (Array.isArray(res?.items)) return res.items;
243
112
  // Captured host executor without batch support: fan out.
244
113
  return Promise.all(p.map((item) => read(item, a, b)));
@@ -277,8 +146,13 @@ function buildGuestApi(available) {
277
146
 
278
147
  function makeConsole(runId, limits) {
279
148
  let count = 0;
149
+ let truncated = false;
150
+ const markTruncated = () => {
151
+ if (!truncated) post({ op: "logTruncated", runId });
152
+ truncated = true;
153
+ };
280
154
  const emit = (...args) => {
281
- if (count >= limits.maxLogLines) return;
155
+ if (count >= limits.maxLogLines) { markTruncated(); return; }
282
156
  count++;
283
157
  const line = args
284
158
  .map((a) => {
@@ -290,30 +164,34 @@ function makeConsole(runId, limits) {
290
164
  }
291
165
  })
292
166
  .join(" ");
293
- post({ op: "log", runId, line: line.length > limits.maxLogLineChars ? line.slice(0, limits.maxLogLineChars) + "…" : line });
167
+ const clipped = truncateChars(line, limits.maxLogLineChars, "log");
168
+ if (clipped.truncated) markTruncated();
169
+ post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
294
170
  };
295
171
  return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
296
172
  }
297
173
 
298
174
  function postFailure(runId, err, location) {
175
+ runActive = false;
299
176
  const message = err instanceof Error ? err.message : String(err);
300
177
  post({ op: "error", runId, message, location });
301
178
  }
302
179
 
303
180
  async function handleRun(msg) {
304
- const { runId, code, limits, available } = msg;
181
+ const { runId, prepared, limits, available, batchRead = true } = msg;
305
182
  activeRunId = runId;
183
+ runActive = true;
306
184
  let compiled;
307
185
  try {
308
- compiled = compile(code);
186
+ compiled = { fn: new AsyncFunction(...PARAMS, prepared.body), hasReturn: prepared.hasReturn };
309
187
  } catch (err) {
310
188
  postFailure(runId, err);
311
189
  return;
312
190
  }
313
- const api = buildGuestApi(available);
191
+ const api = buildGuestApi(available, batchRead, runId);
314
192
  const scopedConsole = makeConsole(runId, limits);
315
193
  try {
316
- const value = await compiled(
194
+ const value = await compiled.fn(
317
195
  api.nova, scopedConsole, runParallel, runPipeline,
318
196
  api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
319
197
  );
@@ -324,7 +202,8 @@ async function handleRun(msg) {
324
202
  } catch (err) {
325
203
  plain = "[unserializable: " + (err?.message || err) + "]";
326
204
  }
327
- post({ op: "done", runId, value: plain, undefinedReturn: value === undefined, hasReturn: /\breturn\b/.test(code) });
205
+ runActive = false;
206
+ post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
328
207
  } catch (err) {
329
208
  if (runId !== activeRunId) return;
330
209
  postFailure(runId, err, guestLocation(err));
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";
@@ -79,7 +79,7 @@ function applyReplacements(target, content, requestedEdits) {
79
79
  if (index < 0) {
80
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)`);
81
81
  }
82
- if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
82
+ if (content.indexOf(replacement.oldText, index + 1) >= 0) {
83
83
  throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
84
84
  }
85
85
  return { ...replacement, index, end: index + replacement.oldText.length };
@@ -113,15 +113,16 @@ async function formatLsEntry(dirPath, entry) {
113
113
 
114
114
  function createNativeAdapters(getCwd, vfs, config, index, ledger) {
115
115
  async function readAdapter(params, signal) {
116
+ signal?.throwIfAborted();
116
117
  const cwd = getCwd();
117
118
  const targetParam = params?.path ?? params?.target;
118
119
 
119
120
  if (Array.isArray(targetParam)) {
120
121
  const results = await Promise.all(
121
- targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
122
+ targetParam.map((p) => readAdapter({ ...params, path: p }, signal)),
122
123
  );
123
124
  const items = results.map((r) => r.content[0].text);
124
- return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
125
+ return textResult("", { count: results.length, batch: true, items });
125
126
  }
126
127
 
127
128
  if (looksLikePath(targetParam)) {
@@ -281,7 +282,8 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
281
282
  const cwd = getCwd();
282
283
  const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
283
284
  if (signal?.aborted) throw new Error("aborted");
284
- const content = String(params?.content ?? "");
285
+ if (!isString(params?.content)) throw new Error("write requires string content");
286
+ const content = params.content;
285
287
  let prevText = "";
286
288
  try {
287
289
  prevText = await vfs.read(target);
@@ -308,7 +310,6 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
308
310
  matches.length === 1
309
311
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
310
312
  : buildMultiEditDiff(target, content, matches);
311
- const tag = speculative ? " (speculative)" : "";
312
313
  const summary = await editSummary(cwd, target, content, updated, diff);
313
314
  return textResult(summary, { path: target, speculative, diff });
314
315
  },
@@ -367,7 +368,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
367
368
  const options = {};
368
369
  if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
369
370
  if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
370
- 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 });
371
372
  for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
372
373
  return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
373
374
  },
@@ -408,7 +409,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
408
409
  if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
409
410
  return {
410
411
  content: [{ type: "text", text }],
411
- details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
412
+ details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
412
413
  isError: res.exitCode !== 0,
413
414
  };
414
415
  },
@@ -474,19 +475,24 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
474
475
  };
475
476
  }
476
477
 
477
- export function createHostBridge({ pi, config, getCwd }) {
478
- const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
479
- const ledger = new SeenLedger({ window: config.seenWindow ?? 40 });
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 });
480
481
  const vfs = new CausalVfs(() => index.invalidate());
481
- const executors = new Map();
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;
482
486
  const natives = createNativeAdapters(getCwd, vfs, config, index, ledger);
483
487
  let callCount = 0;
484
488
  let activeCtx = null;
489
+ let hostSession = null;
490
+ let boundSessionId;
485
491
  let activeSignal = undefined;
486
492
  let trace = [];
487
493
  let callListener = null;
488
494
 
489
- if (pi && isFunction(pi.registerTool)) {
495
+ if (!registry && pi && isFunction(pi.registerTool)) {
490
496
  const original = pi.registerTool.bind(pi);
491
497
  const excluded = new Set(config.excludeTools || []);
492
498
  pi.registerTool = (tool) => {
@@ -498,6 +504,7 @@ export function createHostBridge({ pi, config, getCwd }) {
498
504
  !excluded.has(tool.name)
499
505
  ) {
500
506
  executors.set(tool.name, tool.execute.bind(tool));
507
+ definitions.set(tool.name, tool);
501
508
  }
502
509
  return original(tool);
503
510
  };
@@ -505,10 +512,52 @@ export function createHostBridge({ pi, config, getCwd }) {
505
512
 
506
513
  function bindCallContext(ctx, signal) {
507
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;
508
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));
509
556
  }
510
557
 
511
558
  function resetCallBudget() {
559
+ closed = false;
560
+ vfs.closed = false;
512
561
  callCount = 0;
513
562
  trace = [];
514
563
  // Files may change between programs (editor, git); never serve a stale run.
@@ -556,6 +605,7 @@ export function createHostBridge({ pi, config, getCwd }) {
556
605
  }
557
606
 
558
607
  function checkCallBudget(name) {
608
+ if (closed) throw new Error("program is already complete");
559
609
  const maxCalls = config.maxBridgeCalls ?? 256;
560
610
  callCount += 1;
561
611
  if (callCount > maxCalls) {
@@ -598,23 +648,35 @@ export function createHostBridge({ pi, config, getCwd }) {
598
648
 
599
649
  async function invokeRaw(name, args) {
600
650
  checkCallBudget(name);
651
+ const callId = ++sharedRegistry.callSeq;
601
652
  assertCallableTarget(name);
653
+ if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
602
654
 
603
655
  const record = { name, args: args || {}, time: Date.now() };
604
656
  trace.push(record);
605
657
  notifyCall(record);
606
658
 
607
659
  try {
608
- const exec = executors.get(name);
660
+ const delegated = hostTool(name);
661
+ const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
609
662
  if (exec) {
610
663
  const fallbackDiff = await writeFallbackDiff(name, args);
611
- if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
612
- const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
613
- completeRecord(record, res, fallbackDiff);
614
- 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
+ }
615
677
  }
616
678
 
617
- const native = natives[name];
679
+ const native = Object.hasOwn(natives, name) ? natives[name] : undefined;
618
680
  if (native) {
619
681
  const res = await native(args || {}, activeSignal);
620
682
  completeRecord(record, res);
@@ -633,7 +695,7 @@ export function createHostBridge({ pi, config, getCwd }) {
633
695
 
634
696
  function finishRecord(record, res) {
635
697
  record.ms = Date.now() - record.time;
636
- record.ok = res?.isError !== true && res?.details?.ok !== false;
698
+ record.ok = !hostResultFailed(res);
637
699
  const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
638
700
  if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
639
701
  }
@@ -645,14 +707,16 @@ export function createHostBridge({ pi, config, getCwd }) {
645
707
  }
646
708
 
647
709
  async function callMany(calls) {
648
- 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");
649
713
  const thunks = list.map((item) => {
650
714
  const n = item?.name;
651
715
  const a = item?.args;
652
716
  return () => call(n, a);
653
717
  });
654
718
  const names = list.map((item) => item?.name).filter((n) => isString(n));
655
- 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 });
656
720
  // Return a results array that also carries .mode/.reason, and is directly
657
721
  // iterable so `for (const r of await nova.callMany([...]))` works.
658
722
  const results = Array.isArray(wave.results) ? wave.results.slice() : [];
@@ -666,7 +730,16 @@ export function createHostBridge({ pi, config, getCwd }) {
666
730
 
667
731
  return {
668
732
  executors,
733
+ definitions,
669
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; },
670
743
  bindCallContext,
671
744
  resetCallBudget,
672
745
  getTrace,