pi-supernova 0.2.0 → 0.3.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.
Files changed (32) hide show
  1. package/README.md +196 -196
  2. package/{CHANGELOG.md → docs/CHANGELOG.md} +44 -1
  3. package/index.js +81 -68
  4. package/package.json +12 -31
  5. package/{catalog.js → src/bridge/catalog.js} +7 -5
  6. package/{host-bridge.js → src/bridge/host-bridge.js} +232 -87
  7. package/src/bridge/native-tools.js +155 -0
  8. package/src/bridge/pi-extension.ts +2 -0
  9. package/{config.js → src/config/config.js} +1 -1
  10. package/{evidence.js → src/context/evidence.js} +23 -12
  11. package/{outline.js → src/context/outline.js} +1 -1
  12. package/{repo-index.js → src/context/repo-index.js} +11 -9
  13. package/{search.js → src/context/search.js} +34 -1
  14. package/{snap.js → src/context/snap.js} +51 -31
  15. package/{surface.js → src/context/surface.js} +1 -1
  16. package/{diff.js → src/fs/diff.js} +7 -5
  17. package/{patch.js → src/fs/patch.js} +1 -1
  18. package/{vfs.js → src/fs/vfs.js} +55 -14
  19. package/{workspace.js → src/fs/workspace.js} +22 -6
  20. package/{bottleneck.js → src/output/bottleneck.js} +23 -6
  21. package/{format.js → src/output/format.js} +20 -1
  22. package/{guest-worker.js → src/runtime/guest-worker.js} +90 -21
  23. package/{parallel.js → src/runtime/parallel.js} +68 -1
  24. package/{runtime.js → src/runtime/runtime.js} +14 -5
  25. package/{omp-frame.js → src/ui/omp-frame.js} +1 -1
  26. package/{render-measure.js → src/ui/render-measure.js} +27 -1
  27. package/{render.js → src/ui/render.js} +42 -20
  28. /package/{config.default.json → src/config/config.default.json} +0 -0
  29. /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
  30. /package/{ledger.js → src/context/ledger.js} +0 -0
  31. /package/{check.js → src/fs/check.js} +0 -0
  32. /package/{decode.js → src/shared/decode.js} +0 -0
@@ -1,14 +1,18 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { isString } from "./decode.js";
3
+ import { isString } from "../shared/decode.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
 
6
6
  const VFS_CACHE_MAX = 1024;
7
+ // Serialize validation + replacement across Supernova transactions in this host.
8
+ let commitTail = Promise.resolve();
7
9
 
8
10
  export class CausalVfs {
9
- constructor(onNewFile) {
11
+ constructor(onNewFile, validateWrite) {
12
+ this.validateWrite = validateWrite;
10
13
  this.cache = new Map();
11
14
  this.overlays = [];
15
+ this.expected = new Map();
12
16
  this.onNewFile = onNewFile;
13
17
  this.closed = false;
14
18
  this.signal = undefined;
@@ -34,19 +38,19 @@ export class CausalVfs {
34
38
  return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
35
39
  }
36
40
 
37
- async read(target) {
41
+ async read(target, { preserveRead = false } = {}) {
38
42
  const overlay = this.getOverlay(target);
39
43
  if (overlay !== undefined) return overlay;
40
44
  // External editors and captured tools can change a file between any two reads.
41
45
  try {
42
46
  const text = await fs.readFile(target, "utf8");
43
- this.setCache(target, text);
47
+ if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
44
48
  return text;
45
49
  } catch (err) {
46
50
  this.cache.delete(target);
47
51
  if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
48
52
  if (err.code === "ENOENT") {
49
- const missing = new Error("no such file: " + target + ' (locate it with nova.call("glob", {pattern}) or snap(query))');
53
+ const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
50
54
  missing.code = "ENOENT";
51
55
  throw missing;
52
56
  }
@@ -63,6 +67,13 @@ export class CausalVfs {
63
67
  if (err.code !== "ENOENT") throw err;
64
68
  }
65
69
  this.assertWritable();
70
+ if (this.getOverlay(target) === undefined) {
71
+ let original;
72
+ try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
73
+ catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
74
+ this.expected.set(target, original);
75
+ }
76
+ this.assertWritable();
66
77
  if (this.overlays.length) {
67
78
  this.overlays.at(-1).set(target, content);
68
79
  return { speculative: true };
@@ -79,7 +90,14 @@ export class CausalVfs {
79
90
 
80
91
  /** Stage every file and its backup before replacing any destination. */
81
92
  async flush(writes) {
93
+ const work = commitTail.then(() => this.flushWrites(writes));
94
+ commitTail = work.catch(() => {});
95
+ return work;
96
+ }
97
+
98
+ async flushWrites(writes) {
82
99
  const staged = [];
100
+ const targets = new Set();
83
101
  const createdDirs = [];
84
102
  let failed = false;
85
103
  try {
@@ -94,6 +112,15 @@ export class CausalVfs {
94
112
  } catch (err) {
95
113
  if (err.code !== "ENOENT") throw err;
96
114
  }
115
+ await this.validateWrite?.(logicalPath);
116
+ if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
117
+ targets.add(target);
118
+ if (this.expected.has(logicalPath)) {
119
+ const current = stat ? await fs.readFile(target, "utf8") : null;
120
+ if (current !== this.expected.get(logicalPath)) {
121
+ throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
122
+ }
123
+ }
97
124
  const parent = path.dirname(target);
98
125
  const missing = [];
99
126
  let probe = parent;
@@ -109,18 +136,27 @@ export class CausalVfs {
109
136
  const token = ".supernova-" + randomUUID();
110
137
  const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
111
138
  staged.push(entry);
112
- await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
113
- if (stat) {
114
- await fs.chmod(entry.temporary, stat.mode & 0o7777);
115
- await fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL);
116
- }
139
+ const replacement = (async () => {
140
+ await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
141
+ if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
142
+ })();
143
+ // These touch separate staging files. Settle both before cleanup, even
144
+ // on failure: Promise.all could leave a late backup after rollback.
145
+ const staging = [replacement];
146
+ if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
147
+ const outcomes = await Promise.allSettled(staging);
148
+ const failure = outcomes.find(outcome => outcome.status === "rejected");
149
+ if (failure) throw failure.reason;
117
150
  }
118
151
  for (const entry of staged) {
119
152
  this.signal?.throwIfAborted();
120
153
  await fs.rename(entry.temporary, entry.target);
121
154
  entry.replaced = true;
122
155
  }
123
- for (const entry of staged) this.setCache(entry.logicalPath, entry.content);
156
+ for (const entry of staged) {
157
+ this.setCache(entry.logicalPath, entry.content);
158
+ this.expected.delete(entry.logicalPath);
159
+ }
124
160
  if (staged.length) this.onNewFile?.();
125
161
  } catch (error) {
126
162
  failed = true;
@@ -141,8 +177,10 @@ export class CausalVfs {
141
177
  throw error;
142
178
  } finally {
143
179
  for (const entry of staged) {
144
- await fs.rm(entry.temporary, { force: true }).catch(() => {});
145
- if (!entry.keepBackup) await fs.rm(entry.backup, { force: true }).catch(() => {});
180
+ // A successful rename consumed the temporary path. These are known
181
+ // files, so unlink avoids rm's extra type probe; missing files stay benign.
182
+ if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
183
+ if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
146
184
  }
147
185
  if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
148
186
  }
@@ -163,12 +201,15 @@ export class CausalVfs {
163
201
 
164
202
  rollback() {
165
203
  const top = this.overlays.pop();
204
+ for (const target of top?.keys() ?? []) {
205
+ if (this.getOverlay(target) === undefined) this.expected.delete(target);
206
+ }
166
207
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
167
208
  }
168
209
 
169
210
  async prepareExternalMutation(name) {
170
211
  this.assertWritable();
171
- if (this.overlays.length > 1) throw new Error(name + " cannot run inside nova.speculate because external mutations cannot be rolled back");
212
+ if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
172
213
  if (!this.overlays.length) return false;
173
214
  const pending = this.overlays[0];
174
215
  await this.flush(pending);
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { constants } from "node:os";
5
- import { isString } from "./decode.js";
5
+ import { isString } from "../shared/decode.js";
6
6
 
7
7
  let cachedCwd = null;
8
8
  let cachedResolvedCwd = null;
@@ -56,7 +56,7 @@ export function isTestPath(filePath) {
56
56
  return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
57
57
  }
58
58
 
59
- export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
59
+ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
60
60
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
61
61
  throw new Error(`${opName} requires path`);
62
62
  }
@@ -71,7 +71,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
71
71
  realRoot = await fs.realpath(resolvedCwd);
72
72
  realRoots.set(resolvedCwd, realRoot);
73
73
  }
74
- let probe = realNearest.get(target);
74
+ let probe = fresh ? undefined : realNearest.get(target);
75
75
  if (!probe) {
76
76
  probe = await realpathNearest(target);
77
77
  if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
@@ -88,7 +88,7 @@ export async function runCommand(argv, options = {}) {
88
88
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
89
89
  return new Promise((resolve, reject) => {
90
90
  const child = spawn(argv[0], argv.slice(1), {
91
- cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
91
+ cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
92
92
  });
93
93
  let stdout = "";
94
94
  let stderr = "";
@@ -105,6 +105,13 @@ export async function runCommand(argv, options = {}) {
105
105
  if (settled) return;
106
106
  settled = true;
107
107
  cleanup();
108
+ // Keep bounded diagnostic output when a command times out or is cancelled.
109
+ error.stdout = stdout;
110
+ error.stderr = stderr;
111
+ error.outputTruncated = outputTruncated;
112
+ const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
113
+ if (output) error.message += "\n" + output;
114
+ if (outputTruncated) error.message += "\n[output truncated]";
108
115
  reject(error);
109
116
  };
110
117
  const signalTree = signal => {
@@ -124,7 +131,7 @@ export async function runCommand(argv, options = {}) {
124
131
  escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
125
132
  };
126
133
  const onAbort = () => terminate(new Error("aborted"));
127
- const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + argv.join(" "))), timeoutMs);
134
+ const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
128
135
  const append = (current, chunk) => {
129
136
  const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
130
137
  if (chunk.length > remaining) outputTruncated = true;
@@ -136,7 +143,16 @@ export async function runCommand(argv, options = {}) {
136
143
  child.stderr.on("data", chunk => { stderr = append(stderr, chunk); });
137
144
  child.on("error", fail);
138
145
  child.on("close", (code, signal) => {
139
- if (settled || terminationError) return;
146
+ if (settled) return;
147
+ if (terminationError) {
148
+ // A closed pipe alone says nothing about descendants. Only ESRCH proves
149
+ // the owned POSIX group is gone; otherwise retain the escalation timer.
150
+ if (process.platform !== "win32" && child.pid) {
151
+ try { process.kill(-child.pid, 0); }
152
+ catch (error) { if (error.code === "ESRCH") fail(terminationError); }
153
+ }
154
+ return;
155
+ }
140
156
  settled = true;
141
157
  cleanup();
142
158
  resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
@@ -1,8 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { isString, isObject } from "./decode.js";
5
- import { truncateChars, formatValue } from "./format.js";
4
+ import { isString, isObject } from "../shared/decode.js";
5
+ import { truncateChars, formatReturn } from "./format.js";
6
6
 
7
7
  function json(value) {
8
8
  try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
@@ -80,12 +80,15 @@ export function packageHostResult(raw, config) {
80
80
  const text = batch ? "" : extractRawString(raw);
81
81
  const capped = truncateChars(text, maxChars, "host-result");
82
82
  let truncated = capped.truncated || details?.outputTruncated === true;
83
- const result = { ok: !hostResultFailed(raw), value: capped.text, truncated };
83
+ const image = raw?.content?.find(part => part?.type === "image");
84
+ const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
84
85
  if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
85
86
  if (batch) {
87
+ result.itemErrors = (details.itemErrors ?? []).map(error => error == null ? null : truncateChars(String(error), Math.max(1, Math.floor(maxChars / batch.length)), "error").text);
86
88
  let remaining = maxChars;
87
89
  result.items = batch.map((item, index) => {
88
- const bounded = truncateChars(item, Math.floor(remaining / (batch.length - index)), "host-result");
90
+ if (item?.type === "image") return item;
91
+ const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
89
92
  remaining -= bounded.text.length;
90
93
  truncated ||= bounded.truncated;
91
94
  return bounded.text;
@@ -111,7 +114,21 @@ export function packageHostResult(raw, config) {
111
114
  }
112
115
 
113
116
  export function packageFinalReturn(value, logs, config) {
114
- const serialized = truncateChars(isString(value) ? value : formatValue(value), config.maxReturnChars ?? 32000, "return");
117
+ const images = [];
118
+ let imageBytes = 0;
119
+ const collect = input => {
120
+ if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
121
+ imageBytes += Buffer.byteLength(input.data, "base64");
122
+ if (images.length >= 16 || imageBytes > 20 * 1024 * 1024) throw new Error("returned images exceed 16 attachments or 20 MiB; return fewer or smaller images");
123
+ images.push({ type: "image", data: input.data, mimeType: input.mimeType });
124
+ return `[image ${images.length}: ${input.mimeType}]`;
125
+ }
126
+ if (Array.isArray(input)) return input.map(collect);
127
+ if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collect(child)]));
128
+ return input;
129
+ };
130
+ value = collect(value);
131
+ const serialized = truncateChars(formatReturn(value), config.maxReturnChars ?? 32000, "return");
115
132
  const maxLines = config.maxLogLines ?? 100;
116
133
  let logTruncated = logs.length > maxLines;
117
134
  const clipped = logs.slice(0, maxLines).map(line => {
@@ -120,5 +137,5 @@ export function packageFinalReturn(value, logs, config) {
120
137
  return result.text;
121
138
  });
122
139
  return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
123
- returnTruncated: serialized.truncated, logs: clipped, logTruncated };
140
+ returnTruncated: serialized.truncated, logs: clipped, logTruncated, images };
124
141
  }
@@ -1,4 +1,4 @@
1
- import { isString, isObject } from "./decode.js";
1
+ import { isString, isObject } from "../shared/decode.js";
2
2
 
3
3
  export function truncateChars(text, maxChars, label = "value") {
4
4
  const normalized = isString(text) ? text : String(text ?? "");
@@ -43,6 +43,25 @@ function tailStartIndex(text, tail) {
43
43
  return code >= 0xdc00 && code <= 0xdfff ? start + 1 : start;
44
44
  }
45
45
 
46
+ // Unicode mode matches lone surrogate code points, not valid UTF-16 pairs.
47
+ const UNPAIRED_SURROGATE = /[\uD800-\uDFFF]/u;
48
+
49
+ function hasWellFormedStrings(values) {
50
+ for (const value of values) if (!isString(value) || UNPAIRED_SURROGATE.test(value)) return false;
51
+ return true;
52
+ }
53
+
54
+ /** Lossless framing for source arrays, not string escaping or source compression. */
55
+ export function formatReturn(value) {
56
+ if (isString(value)) return value;
57
+ if (Array.isArray(value) && value.length && hasWellFormedStrings(value) && value.some(text => text.includes("\n"))) {
58
+ const raw = "strings[" + value.length + "]\n" + value.map((text, i) => "[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
59
+ const escapedSize = value.reduce((sum, text) => sum + JSON.stringify(text).length, value.length + 1);
60
+ if (raw.length < escapedSize) return raw;
61
+ }
62
+ return formatValue(value);
63
+ }
64
+
46
65
  const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
47
66
  const FORMAT_WIDTH = 120;
48
67
 
@@ -1,14 +1,14 @@
1
1
  import { parentPort } from "node:worker_threads";
2
- import { parallel as runParallel, pipeline as runPipeline } from "./parallel.js";
3
- import { isString, isObject, toPlain } from "./decode.js";
4
- import { truncateChars } from "./format.js";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { isString, isObject, isFunction, toPlain } from "../shared/decode.js";
4
+ import { truncateChars } from "../output/format.js";
5
5
 
6
6
  // Guest programs run here, off the host thread. The host can terminate() this
7
7
  // worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
8
8
  // code cannot take the harness down with it.
9
9
 
10
10
  const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
11
- const PARAMS = ["nova", "console", "parallel", "pipeline", "read", "write", "edit", "patch", "surface", "snap", "evidence", "bash", "exec", "speculate"];
11
+ const PARAMS = ["console", "read", "edit", "write", "bash"];
12
12
  const BODY_LINE_OFFSET = 2;
13
13
  /** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
14
14
  function guestLocation(err) {
@@ -67,9 +67,15 @@ function leanEnvelope(res) {
67
67
  return res;
68
68
  }
69
69
 
70
- function buildGuestApi(available, batchRead, runId) {
70
+ function buildGuestApi(available, batchRead, runId, nativeArgv) {
71
71
  const rpc = (method, args) => callRpc(runId, method, args);
72
72
  const availableSet = new Set(available);
73
+ const checkpointScope = new AsyncLocalStorage();
74
+ let checkpoint = null;
75
+ const assertScope = () => {
76
+ const token = checkpointScope.getStore();
77
+ if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
78
+ };
73
79
  const nova = {
74
80
  search: (query, limit) => rpc("search", [query, limit]),
75
81
  describe: (name) => rpc("describe", [name]),
@@ -85,15 +91,23 @@ function buildGuestApi(available, batchRead, runId) {
85
91
  return results;
86
92
  },
87
93
  async speculate(fn) {
88
- await rpc("speculateBegin", []);
94
+ if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
95
+ const token = {};
96
+ checkpoint = token;
97
+ let began = false;
89
98
  try {
90
- const value = await fn();
99
+ flushReads();
100
+ await rpc("speculateBegin", []);
101
+ began = true;
102
+ const value = await checkpointScope.run(token, fn);
103
+ flushReads();
91
104
  await rpc("speculateCommit", []);
92
105
  return { ok: true, committed: true, value };
93
106
  } catch (err) {
94
- await rpc("speculateRollback", []);
107
+ flushReads();
108
+ if (began) await rpc("speculateRollback", []);
95
109
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
96
- }
110
+ } finally { checkpoint = null; }
97
111
  },
98
112
  surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
99
113
  evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
@@ -101,24 +115,80 @@ function buildGuestApi(available, batchRead, runId) {
101
115
  has: (name) => availableSet.has(name),
102
116
  };
103
117
 
104
- // read(path, offset?, limit?) or read(path, { about, offset, limit, maxChars })
105
- const readArgs = (p, a, b) => (isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
118
+ // Coalesce already-started compatible reads without rewriting JS control flow.
119
+ let queuedReads = [];
120
+ function flushReads() {
121
+ const pending = queuedReads;
122
+ queuedReads = [];
123
+ for (let start = 0; start < pending.length; start += 64) {
124
+ const wave = pending.slice(start, start + 64);
125
+ const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
126
+ const run = wave.length === 1
127
+ ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapValue(res)], errors: [] }))
128
+ : nova.call("read", args).then(res => { unwrapValue(res); return { values: res.items, errors: res.itemErrors ?? [] }; });
129
+ void run.then(({ values, errors }) => {
130
+ if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
131
+ for (let i = 0; i < wave.length; i++) {
132
+ const value = values[i];
133
+ if (errors[i]) wave[i].reject(new Error(errors[i]));
134
+ else wave[i].resolve(value);
135
+ }
136
+ }).catch(error => { for (const job of wave) job.reject(error); });
137
+ }
138
+ }
139
+ const invoke = (name, args) => { assertScope(); flushReads(); return nova.call(name, args); };
140
+ const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
141
+ ? { ...p, path: p.path ?? p.query }
142
+ : isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
106
143
  const read = async (p, a, b) => {
144
+ assertScope();
145
+ const args = readArgs(p, a, b);
146
+ const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
147
+ p = args.path;
148
+ if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
149
+ if (args.outline) return unwrapJsonValue(await invoke("surface", args));
107
150
  if (Array.isArray(p)) {
108
- if (!batchRead) return Promise.all(p.map(item => read(item, a, b)));
109
- const res = await nova.call("read", readArgs(p, a, b));
151
+ if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
152
+ if (p.some(item => !isString(item) || !item.trim())) throw new Error("read paths must be non-empty strings");
153
+ const readEach = () => Promise.all(p.map(async item => {
154
+ try { return await read({ ...args, path: item }); }
155
+ catch (error) { return `[read error: ${item}] ${error.message}`; }
156
+ }));
157
+ if (!batchRead || args.resolve) return readEach();
158
+ const res = await invoke("read", args);
110
159
  unwrapValue(res);
111
160
  if (Array.isArray(res?.items)) return res.items;
112
161
  // Captured host executor without batch support: fan out.
113
- return Promise.all(p.map((item) => read(item, a, b)));
162
+ return readEach();
114
163
  }
115
- return unwrapValue(await nova.call("read", readArgs(p, a, b)));
164
+ if (!batchRead) return args.resolve ? unwrapJsonValue(await invoke("read", args)) : unwrapValue(await invoke("read", args));
165
+ const key = JSON.stringify({ ...args, path: undefined });
166
+ if (queuedReads.length && queuedReads[0].key !== key) flushReads();
167
+ return new Promise((resolve, reject) => {
168
+ queuedReads.push({ args, key, resolve, reject });
169
+ if (queuedReads.length === 1) queueMicrotask(flushReads);
170
+ }).then(value => args.resolve ? JSON.parse(value) : value);
171
+ };
172
+ const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
173
+ const edit = async (p, oldText, newText) => {
174
+ if (isFunction(p)) return nova.speculate(p);
175
+ const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
176
+ return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
116
177
  };
117
- const write = async (p, content) => unwrapValue(await nova.call("write", { path: p, content }));
118
- const edit = async (p, oldText, newText) => unwrapValue(await nova.call("edit", { path: p, oldText, newText }));
119
178
  const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
120
179
  const bash = async (command, opts) => {
121
- const res = await nova.call("bash", { command, ...opts });
180
+ const args = isObject(command) ? { ...command } : { command, ...opts };
181
+ if (args.args !== undefined) {
182
+ if (!isString(args.command) || !Array.isArray(args.args) || args.args.some(arg => !isString(arg))) throw new Error("bash argv requires a command string and an array of string args");
183
+ if (nativeArgv) args._directArgv = true;
184
+ else {
185
+ delete args._directArgv;
186
+ args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
187
+ }
188
+ }
189
+ command = args.command;
190
+ if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
191
+ const res = await invoke("bash", args);
122
192
  if (res?.ok === false) {
123
193
  let exitCode;
124
194
  try {
@@ -188,12 +258,11 @@ async function handleRun(msg) {
188
258
  postFailure(runId, err);
189
259
  return;
190
260
  }
191
- const api = buildGuestApi(available, batchRead, runId);
261
+ const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
192
262
  const scopedConsole = makeConsole(runId, limits);
193
263
  try {
194
264
  const value = await compiled.fn(
195
- api.nova, scopedConsole, runParallel, runPipeline,
196
- api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
265
+ scopedConsole, api.read, api.edit, api.write, api.bash,
197
266
  );
198
267
  if (runId !== activeRunId) return;
199
268
  let plain;
@@ -1,4 +1,4 @@
1
- import { isFunction } from "./decode.js";
1
+ import { isFunction } from "../shared/decode.js";
2
2
 
3
3
  const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
4
4
  const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
@@ -19,6 +19,73 @@ export function isMutatingTool(name, config = {}, args = {}, definition) {
19
19
  return !READ_ONLY_TOOLS.has(name);
20
20
  }
21
21
 
22
+ /** FIFO read waves with mutation barriers; callers keep independent promises. */
23
+ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
24
+ if (!Number.isInteger(maxParallelReads) || maxParallelReads < 1) throw new Error("maxParallelReads must be a positive integer");
25
+ const queue = [];
26
+ const stats = { calls: 0, readWaves: 0, peakParallelReads: 0 };
27
+ let draining = false;
28
+
29
+ async function drain() {
30
+ try {
31
+ while (queue.length) {
32
+ const first = queue.shift();
33
+ if (first.cancelled) continue;
34
+ const wave = [first];
35
+ if (first.name === "read") {
36
+ while (wave.length < maxParallelReads && queue[0]?.name === "read") {
37
+ const next = queue.shift();
38
+ if (!next.cancelled) wave.push(next);
39
+ }
40
+ stats.readWaves++;
41
+ stats.peakParallelReads = Math.max(stats.peakParallelReads, wave.length);
42
+ }
43
+ // Settle each promise separately: one failed read must not discard its
44
+ // siblings or release a write barrier while other reads are still active.
45
+ await Promise.all(wave.map(async job => {
46
+ if (job.cancelled) return;
47
+ job.started = true;
48
+ job.signal?.removeEventListener("abort", job.abort);
49
+ try {
50
+ job.signal?.throwIfAborted();
51
+ const result = await job.run();
52
+ job.signal?.throwIfAborted();
53
+ job.resolve(result);
54
+ } catch (error) { job.reject(error); }
55
+ }));
56
+ }
57
+ } finally { draining = false; }
58
+ }
59
+
60
+ return {
61
+ stats,
62
+ schedule(name, run, signal) {
63
+ if (!["read", "edit", "write", "bash"].includes(name) || !isFunction(run)) {
64
+ return Promise.reject(new Error("scheduler requires a native tool and an executor"));
65
+ }
66
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
67
+ stats.calls++;
68
+ return new Promise((resolve, reject) => {
69
+ const job = { name, run, signal, resolve, reject, started: false, cancelled: false };
70
+ job.abort = () => {
71
+ if (job.started) return;
72
+ job.cancelled = true;
73
+ signal.removeEventListener("abort", job.abort);
74
+ reject(signal.reason ?? new Error("aborted"));
75
+ };
76
+ signal?.addEventListener("abort", job.abort, { once: true });
77
+ queue.push(job);
78
+ if (!draining) {
79
+ draining = true;
80
+ // Pi submits sibling tools in the same turn without model-side code.
81
+ // Collect those submissions before selecting the first read wave.
82
+ queueMicrotask(() => { void drain(); });
83
+ }
84
+ });
85
+ },
86
+ };
87
+ }
88
+
22
89
  function requireArray(value, name) {
23
90
  if (!Array.isArray(value)) throw new TypeError(name + " requires an array");
24
91
  return value;
@@ -1,8 +1,9 @@
1
1
  import { Worker } from "node:worker_threads";
2
2
  import { parse } from "acorn";
3
3
  import { performance } from "node:perf_hooks";
4
- import { packageFinalReturn } from "./bottleneck.js";
5
- import { isFunction, isObject, isString } from "./decode.js";
4
+ import { packageFinalReturn } from "../output/bottleneck.js";
5
+ import { truncateChars } from "../output/format.js";
6
+ import { isFunction, isObject, isString } from "../shared/decode.js";
6
7
 
7
8
  const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
8
9
  const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
@@ -52,7 +53,10 @@ function prepareProgram(code) {
52
53
 
53
54
  function spawnWorker(config) {
54
55
  const maxHeapMb = config.maxHeapMb ?? 512;
55
- const worker = new Worker(WORKER_URL, { resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
56
+ // An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
57
+ // Keep Node's automatic flag inheritance: explicitly copying execArgv can
58
+ // reintroduce process-only V8 flags that Worker rejects under node --test.
59
+ const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
56
60
  const handle = { worker, maxHeapMb, dead: false, ready: null };
57
61
  // This listener also owns errors between readiness and a run's listeners.
58
62
  worker.on("error", () => { handle.dead = true; });
@@ -111,6 +115,10 @@ export function warmGuestWorker(config = {}) {
111
115
  return handle.ready;
112
116
  }
113
117
 
118
+ export function stopWarmGuestWorker() {
119
+ return killWorker(idleWorker);
120
+ }
121
+
114
122
  const RPC_METHODS = {
115
123
  call: (nova, args) => nova.call(args[0], args[1]),
116
124
  callMany: async (nova, args) => {
@@ -128,7 +136,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
128
136
  const started = performance.now();
129
137
  const wall = () => Math.round(performance.now() - started);
130
138
  const logs = [];
131
- const fail = (error) => ({ ok: false, error, logs, logTruncated, wallMs: wall() });
139
+ const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
132
140
  let logTruncated = false;
133
141
  if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
134
142
  if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters");
@@ -234,7 +242,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
234
242
  try {
235
243
  const packed = packageFinalReturn(msg.value, logs, config);
236
244
  void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
237
- returnTruncated: packed.returnTruncated, undefinedReturn: msg.undefinedReturn === true,
245
+ returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
238
246
  logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
239
247
  } catch (err) {
240
248
  void complete(fail(err.message));
@@ -260,6 +268,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
260
268
  if (wall() >= timeoutMs) return abort();
261
269
  handle.worker.postMessage({ op: "run", runId, prepared, available,
262
270
  batchRead: nova.batchRead !== false,
271
+ nativeArgv: nova.nativeArgv === true,
263
272
  limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
264
273
  } catch (err) {
265
274
  cancelHost();
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { clampLine, measureWidth } from "./render-measure.js";
9
- import { isFunction, isString } from "./decode.js";
9
+ import { isFunction, isString } from "../shared/decode.js";
10
10
 
11
11
  const DEFAULT_BOX = {
12
12
  topLeft: "╭",