pi-supernova 0.6.0 → 0.7.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.
Files changed (49) hide show
  1. package/README.md +27 -3
  2. package/docs/CHANGELOG.md +114 -0
  3. package/docs/TOKEN_COSTS.md +13 -5
  4. package/index.js +120 -79
  5. package/package.json +1 -1
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +28 -222
  15. package/src/bridge/host-bridge.js +113 -1668
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -198
  18. package/src/context/evidence.js +140 -76
  19. package/src/context/fuzzy.js +42 -24
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +23 -18
  22. package/src/context/repo-index.js +206 -170
  23. package/src/context/search.js +157 -77
  24. package/src/context/snap.js +240 -136
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +14 -5
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +12 -8
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +94 -50
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +205 -175
  37. package/src/fs/workspace.js +119 -108
  38. package/src/output/bottleneck.js +195 -116
  39. package/src/output/format.js +101 -67
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +296 -292
  42. package/src/runtime/parallel.js +97 -64
  43. package/src/runtime/program-batch.js +178 -69
  44. package/src/runtime/reference.js +15 -14
  45. package/src/runtime/runtime.js +327 -187
  46. package/src/shared/decode.js +58 -36
  47. package/src/ui/omp-frame.js +59 -42
  48. package/src/ui/render-measure.js +51 -29
  49. package/src/ui/render.js +241 -145
@@ -1,8 +1,35 @@
1
1
  import { parentPort } from "node:worker_threads";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { isString, isObject, isFunction, isNumber, toPlain, looksLikePath } from "../shared/decode.js";
3
+ import * as nodeModule from "node:module";
4
+ import { isString, isObject, isFunction, toPlain, looksLikePath } from "../shared/decode.js";
4
5
  import { truncateChars } from "../output/format.js";
5
- import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
6
+ import { gatherReadArgs, normalizeRead, decodeReadValue, assertReadPaths } from "../contract/read.js";
7
+ import { classifyEdit } from "../contract/edit.js";
8
+ import { normalizeBash } from "../contract/bash.js";
9
+ import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
10
+
11
+ const { register, registerHooks } = nodeModule;
12
+
13
+ if (isFunction(registerHooks)) {
14
+ registerHooks({
15
+ resolve(specifier, context, nextResolve) {
16
+ if (isDeniedGuestImport(specifier)) {
17
+ const error = new Error(guestImportMessage(specifier));
18
+ error.code = "ERR_GUEST_IMPORT";
19
+ throw error;
20
+ }
21
+
22
+ return nextResolve(specifier, context);
23
+ },
24
+ });
25
+ } else if (isFunction(register)) {
26
+ try {
27
+ register("./guest-deny-imports.js", import.meta.url);
28
+ } catch {
29
+ // Hosts whose module.register cannot run loader hooks lose the deny list;
30
+ // the guest remains trusted code, not a sandbox boundary.
31
+ }
32
+ }
6
33
 
7
34
  // Guest programs run here, off the host thread. The host can terminate() this
8
35
  // worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
@@ -14,6 +41,10 @@ const PARAMS = ["console", "read", "edit", "write", "bash"];
14
41
 
15
42
  const BODY_LINE_OFFSET = 2;
16
43
 
44
+ const QUERY_URI = /^(agent|artifact):\/\/.*\?/i;
45
+
46
+ const PER_PATH_HINT = "; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes";
47
+
17
48
  /** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
18
49
  function guestLocation(err) {
19
50
  const stack = String(err?.stack);
@@ -71,16 +102,6 @@ function unwrapRead(res, args) {
71
102
  return value;
72
103
  }
73
104
 
74
- function unwrapJsonValue(res) {
75
- const value = unwrapValue(res);
76
-
77
- try {
78
- return JSON.parse(value);
79
- } catch {
80
- return value;
81
- }
82
- }
83
-
84
105
  /** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
85
106
  function leanEnvelope(res) {
86
107
  if (!isObject(res)) return res;
@@ -94,338 +115,310 @@ function leanEnvelope(res) {
94
115
  return res;
95
116
  }
96
117
 
97
- function quoteShellArg(value) {
98
- return "'" + String(value).replaceAll("'", "'\\''") + "'";
99
- }
100
-
101
- function buildGuestApi(available, batchRead, runId, _nativeArgv) {
102
- const rpc = (method, args) => callRpc(runId, method, args);
103
- const availableSet = new Set(available);
104
- const checkpointScope = new AsyncLocalStorage();
105
- let checkpoint = null;
118
+ function swallow(promise) {
119
+ promise.catch(() => {});
106
120
 
107
- const assertScope = () => {
108
- const token = checkpointScope.getStore();
109
-
110
- if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
111
- };
112
-
113
- let operationTail = Promise.resolve();
114
-
115
- const enqueueHost = operation => {
116
- const next = operationTail.then(operation);
117
- operationTail = next.then(() => {}, () => {});
121
+ return promise;
122
+ }
118
123
 
119
- return next;
120
- };
124
+ function throwReadPathError(target, detail) {
125
+ throw new Error(`read failed for ${target}: ${detail}${PER_PATH_HINT}`);
126
+ }
121
127
 
122
- const nova = {
123
- call(name, args) {
124
- assertScope(); flushReads();
125
- const promise = enqueueHost(() => rpc("call", [name, args]).then(leanEnvelope));
128
+ function isQueryUri(item) {
129
+ return QUERY_URI.test(item);
130
+ }
126
131
 
127
- promise.catch(() => {});
132
+ function firstItemErrorIndex(res) {
133
+ return res?.itemErrors?.findIndex(error => error != null) ?? -1;
134
+ }
128
135
 
129
- return promise;
130
- },
131
- callMany(calls) {
132
- assertScope(); flushReads();
133
- const promise = enqueueHost(async () => {
134
- const wave = await rpc("callMany", [calls]);
135
- const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
136
- Object.defineProperties(results, {
137
- mode: { value: wave?.mode, enumerable: false },
138
- reason: { value: wave?.reason, enumerable: false },
139
- results: { value: results, enumerable: false },
140
- });
136
+ function missingResolvedIndex(values, paths) {
137
+ return values.findIndex((value, index) => value?.status === "not_found" && looksLikePath(paths[index]));
138
+ }
141
139
 
142
- return results;
143
- });
140
+ async function rpcReadWave(rpc, wave) {
141
+ if (wave.length === 1) {
142
+ const res = leanEnvelope(await rpc("call", ["read", wave[0].args]));
144
143
 
145
- promise.catch(() => {});
144
+ return { values: [unwrapRead(res, wave[0].args)], errors: [] };
145
+ }
146
+ const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
147
+ const res = leanEnvelope(await rpc("call", ["read", args]));
148
+ unwrapRead(res, args);
146
149
 
147
- return promise;
148
- },
149
- async speculate(fn) {
150
- if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
151
- const token = {};
152
- checkpoint = token;
153
- let began = false;
150
+ return { values: res.items, errors: res.itemErrors ?? [] };
151
+ }
154
152
 
155
- try {
156
- await drainReads();
157
- await enqueueHost(() => rpc("speculateBegin", []));
158
- began = true;
159
- const value = await checkpointScope.run(token, fn);
160
- await drainReads();
161
- await enqueueHost(() => rpc("speculateCommit", []));
153
+ function settleReadWave(wave, values, errors) {
154
+ if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
162
155
 
163
- return { ok: true, committed: true, value };
164
- } catch (err) {
165
- await drainReads();
156
+ for (let i = 0; i < wave.length; i++) {
157
+ if (errors[i]) wave[i].reject(new Error(errors[i]));
158
+ else wave[i].resolve(values[i]);
159
+ }
160
+ }
166
161
 
167
- if (began) await enqueueHost(() => rpc("speculateRollback", []));
162
+ function rejectReadWave(wave, error) {
163
+ for (const job of wave) job.reject(error);
164
+ }
168
165
 
169
- return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
170
- } finally { checkpoint = null; }
171
- },
172
- surface(filePath) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["surface", { path: filePath }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
173
- evidence(query, opts) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["evidence", { query, ...opts }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
174
- snap(query, targetPath) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["snap", { query, path: targetPath }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
175
- has: (name) => availableSet.has(name),
176
- };
166
+ function dispatchReadWaves(pending, pendingReadWaves, enqueueHost, rpc) {
167
+ for (let start = 0; start < pending.length; start += 64) {
168
+ const wave = pending.slice(start, start + 64);
169
+ const delivery = enqueueHost(() => rpcReadWave(rpc, wave))
170
+ .then(({ values, errors }) => settleReadWave(wave, values, errors))
171
+ .catch(error => rejectReadWave(wave, error));
177
172
 
178
- // Coalesce already-started compatible reads without rewriting JS control flow.
179
- let queuedReads = [];
180
- const pendingReadWaves = new Set();
173
+ pendingReadWaves.add(delivery);
174
+ void delivery.finally(() => pendingReadWaves.delete(delivery));
175
+ }
176
+ }
181
177
 
182
- function flushReads() {
183
- const pending = queuedReads;
184
- queuedReads = [];
178
+ function enqueueCompatibleRead(readState, flushReads, args, decode) {
179
+ const key = JSON.stringify({ ...args, path: undefined });
185
180
 
186
- for (let start = 0; start < pending.length; start += 64) {
187
- const wave = pending.slice(start, start + 64);
188
- const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
181
+ if (readState.queued.length && readState.queued[0].key !== key) flushReads();
189
182
 
190
- const run = enqueueHost(() => wave.length === 1
191
- ? rpc("call", ["read", wave[0].args]).then(leanEnvelope).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
192
- : rpc("call", ["read", args]).then(leanEnvelope).then(res => { unwrapRead(res, args);
183
+ const promise = new Promise((resolve, reject) => {
184
+ readState.queued.push({ args, key, resolve, reject });
193
185
 
194
- return { values: res.items, errors: res.itemErrors ?? [] }; }));
186
+ if (readState.queued.length === 1) queueMicrotask(flushReads);
187
+ }).then(decode);
195
188
 
196
- const delivery = run.then(({ values, errors }) => {
197
- if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
189
+ return swallow(promise);
190
+ }
198
191
 
199
- for (let i = 0; i < wave.length; i++) {
200
- const value = values[i];
192
+ async function readManyPaths(readOne, invoke, batchRead, args, paths, decode) {
193
+ assertReadPaths(paths);
194
+ const readEach = async () => {
195
+ const values = await Promise.all(paths.map(item => readOne({ ...args, path: item })));
196
+ const missing = args.resolve ? missingResolvedIndex(values, paths) : -1;
201
197
 
202
- if (errors[i]) wave[i].reject(new Error(errors[i]));
203
- else wave[i].resolve(value);
204
- }
205
- }).catch(error => { for (const job of wave) job.reject(error); });
198
+ if (missing >= 0) throwReadPathError(paths[missing], "not_found");
206
199
 
207
- pendingReadWaves.add(delivery);
208
- void delivery.finally(() => pendingReadWaves.delete(delivery));
209
- }
210
- }
200
+ return values;
201
+ };
211
202
 
212
- async function drainReads() {
213
- for (;;) {
214
- flushReads();
215
- const waves = [...pendingReadWaves];
203
+ const guardedReadEach = () => swallow(readEach());
216
204
 
217
- if (!waves.length) return;
218
- await Promise.allSettled(waves);
219
- }
220
- }
205
+ if (!batchRead || args.resolve || paths.some(isQueryUri)) return guardedReadEach();
206
+ const res = await invoke("read", args);
207
+ const failed = firstItemErrorIndex(res);
221
208
 
222
- const invoke = (name, args) => {
223
- assertScope(); flushReads();
224
- const promise = nova.call(name, args);
209
+ if (failed >= 0) throwReadPathError(paths[failed], res.itemErrors[failed]);
210
+ unwrapRead(res, args);
225
211
 
226
- promise.catch(() => {});
212
+ if (Array.isArray(res?.items)) return res.items.map(decode);
227
213
 
228
- return promise;
229
- };
214
+ return guardedReadEach();
215
+ }
230
216
 
231
- const readArgs = (p, a, b) => {
232
- if (isObject(p) && !Array.isArray(p)) {
233
- const args = { ...p, path: p.path ?? p.target ?? p.query };
217
+ function attachCallManyMeta(wave) {
218
+ const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
219
+ Object.defineProperties(results, {
220
+ mode: { value: wave?.mode, enumerable: false },
221
+ reason: { value: wave?.reason, enumerable: false },
222
+ results: { value: results, enumerable: false },
223
+ });
234
224
 
235
- if (p.path === undefined && p.target !== undefined) delete args.target;
225
+ return results;
226
+ }
236
227
 
237
- return args;
238
- }
228
+ async function runSpeculation(fn, token, checkpointScope, drainReads, enqueueHost, rpc) {
229
+ let began = false;
239
230
 
240
- return isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
241
- };
231
+ try {
232
+ await drainReads();
233
+ await enqueueHost(() => rpc("speculateBegin", []));
234
+ began = true;
235
+ const value = await checkpointScope.run(token, fn);
236
+ await drainReads();
237
+ await enqueueHost(() => rpc("speculateCommit", []));
238
+
239
+ return { ok: true, committed: true, value };
240
+ } catch (err) {
241
+ await drainReads();
242
242
 
243
- const read = async (p, a, b) => {
244
- assertScope();
245
- const args = sessionJsonArgs(readArgs(p, a, b));
246
- if (isString(args.path) && args.resolve === undefined && args.complete !== true && args.json === undefined && args.about === undefined && args.query === undefined && !looksLikePath(args.path) && !/^(?:agent|artifact):\/\//i.test(args.path)) {
247
- args.resolve = true;
248
- }
249
- validateJsonRead(args);
243
+ if (began) await enqueueHost(() => rpc("speculateRollback", []));
250
244
 
251
- for (const [key, value] of [["resolve", args.resolve], ["complete", args.complete], ["outline", args.outline], ["evidence", args.evidence]]) {
252
- if (value !== undefined && typeof value !== "boolean") throw new Error("read " + key + " must be a boolean");
253
- }
245
+ return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
246
+ }
247
+ }
254
248
 
255
- if (args.about !== undefined && !isString(args.about)) throw new Error("read about must be a string");
256
- if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
257
- const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
249
+ function formatBashFailure(command, res) {
250
+ let exitCode;
258
251
 
259
- if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
260
- if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
261
- const decode = value => {
262
- if ((!args.resolve && args.json === undefined) || !isString(value)) return value;
252
+ try {
253
+ exitCode = JSON.parse(res.details).exitCode;
254
+ } catch {}
263
255
 
264
- try {
265
- return JSON.parse(value);
266
- } catch (error) {
267
- const selector = args.json === undefined ? "" : " (" + (Array.isArray(args.json) ? args.json.join(", ") : String(args.json)) + ")";
268
- throw new Error("JSON read failed for " + String(args.path ?? args.target ?? "resource") + selector + ": " + (error instanceof Error ? error.message : String(error)));
269
- }
270
- };
256
+ const output = String(res.value).trimEnd();
257
+ const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
271
258
 
272
- if (args.complete === true && (args.outline || args.evidence || args.about || args.query)) throw new Error("complete:true requires a raw file read, not a source view");
273
- const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : (isString(p) && looksLikePath(p) ? p : undefined);
274
- p = args.path;
259
+ return "command failed" + suffix + ": " + command + (output ? "\n" + output : "");
260
+ }
275
261
 
276
- if (args.evidence) {
277
- const query = args.about ?? args.query ?? (isString(p) ? p : undefined);
278
- return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query }));
279
- }
262
+ function markTruncatedOutput(res, text) {
263
+ if (res?.truncated && isString(text) && !text.includes("truncated")) return text + "\n…[output truncated]…";
280
264
 
281
- if (args.outline) return unwrapJsonValue(await invoke("surface", args));
265
+ return text;
266
+ }
282
267
 
283
- if (Array.isArray(p)) {
284
- if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
268
+ function encodeConsoleArg(a) {
269
+ if (isString(a)) return a;
285
270
 
286
- for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
287
- const readEach = async () => {
288
- const values = await Promise.all(p.map(item => read({ ...args, path: item })));
289
- const missing = args.resolve ? values.findIndex((value, index) => value?.status === "not_found" && looksLikePath(p[index])) : -1;
271
+ try {
272
+ const plain = toPlain(a);
273
+ const encoded = JSON.stringify(plain);
290
274
 
291
- if (missing >= 0) throw new Error(`read failed for ${p[missing]}: not_found; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes`);
275
+ return encoded === undefined ? String(plain) : encoded;
276
+ } catch {
277
+ return String(a);
278
+ }
279
+ }
292
280
 
293
- return values;
294
- };
281
+ function compileGuest(prepared, data) {
282
+ const bindings = data === undefined ? PARAMS : [...PARAMS, "data"];
295
283
 
296
- const guardedReadEach = () => {
297
- const promise = readEach();
284
+ return { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
285
+ }
298
286
 
299
- promise.catch(() => {});
287
+ function plainGuestValue(value) {
288
+ try { return toPlain(value); }
289
+ catch (err) { return "[unserializable: " + (err?.message || err) + "]"; }
290
+ }
300
291
 
301
- return promise;
302
- };
292
+ function handleRpcResult(msg) {
293
+ const pending = pendingRpc.get(msg.id);
303
294
 
304
- if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return guardedReadEach();
305
- const res = await invoke("read", args);
306
- const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
295
+ if (!pending) return;
296
+ pendingRpc.delete(msg.id);
307
297
 
308
- if (failed >= 0) throw new Error(`read failed for ${p[failed]}: ${res.itemErrors[failed]}; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes`);
309
- unwrapRead(res, args);
298
+ if (msg.ok) pending.resolve(msg.value);
299
+ else pending.reject(new Error(msg.error));
300
+ }
310
301
 
311
- if (Array.isArray(res?.items)) return res.items.map(decode);
302
+ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
303
+ const rpc = (method, args) => callRpc(runId, method, args);
304
+ const checkpointScope = new AsyncLocalStorage();
305
+ let checkpoint = null;
312
306
 
313
- // Captured host executor without batch support: fan out.
314
- return guardedReadEach();
315
- }
307
+ const assertScope = () => {
308
+ const token = checkpointScope.getStore();
316
309
 
317
- if (!batchRead) {
318
- const res = await invoke("read", args);
319
- unwrapRead(res, args);
310
+ if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
311
+ };
320
312
 
321
- return decode(unwrapRead(res, args));
322
- }
313
+ let operationTail = Promise.resolve();
323
314
 
324
- const key = JSON.stringify({ ...args, path: undefined });
315
+ const enqueueHost = operation => {
316
+ const next = operationTail.then(operation);
317
+ operationTail = next.then(() => {}, () => {});
325
318
 
326
- if (queuedReads.length && queuedReads[0].key !== key) flushReads();
319
+ return next;
320
+ };
327
321
 
328
- const promise = new Promise((resolve, reject) => {
329
- queuedReads.push({ args, key, resolve, reject });
322
+ const nova = {
323
+ call(name, args) {
324
+ assertScope(); flushReads();
330
325
 
331
- if (queuedReads.length === 1) queueMicrotask(flushReads);
332
- }).then(decode);
326
+ return swallow(enqueueHost(() => rpc("call", [name, args]).then(leanEnvelope)));
327
+ },
328
+ callMany(calls) {
329
+ assertScope(); flushReads();
333
330
 
334
- promise.catch(() => {});
331
+ return swallow(enqueueHost(async () => attachCallManyMeta(await rpc("callMany", [calls]))));
332
+ },
333
+ async speculate(fn) {
334
+ if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
335
+ const token = {};
336
+ checkpoint = token;
335
337
 
336
- return promise;
338
+ try { return await runSpeculation(fn, token, checkpointScope, drainReads, enqueueHost, rpc); }
339
+ finally { checkpoint = null; }
340
+ },
337
341
  };
338
342
 
339
- const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
343
+ // Coalesce already-started compatible reads without rewriting JS control flow.
344
+ const readState = { queued: [], waves: new Set() };
340
345
 
341
- const viewSpan = (value) => {
342
- const start = isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
343
- const end = isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
346
+ function flushReads() {
347
+ const pending = readState.queued;
348
+ readState.queued = [];
349
+ dispatchReadWaves(pending, readState.waves, enqueueHost, rpc);
350
+ }
344
351
 
345
- if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
352
+ async function drainReads() {
353
+ for (;;) {
354
+ flushReads();
355
+ const waves = [...readState.waves];
346
356
 
347
- return { start: Math.floor(start), end: Math.floor(end) };
348
- };
357
+ if (!waves.length) return;
358
+ await Promise.allSettled(waves);
359
+ }
360
+ }
349
361
 
350
- const isView = (value) => isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.trim() && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
362
+ const invoke = (name, args) => {
363
+ assertScope(); flushReads();
351
364
 
352
- const edit = async (p, oldText, newText) => {
353
- if (isFunction(p)) return nova.speculate(p);
354
- const usage = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
365
+ return swallow(nova.call(name, args));
366
+ };
355
367
 
356
- if (isView(p) && isString(oldText) && (newText === undefined || isString(newText))) {
357
- if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
358
- const span = viewSpan(p);
359
- const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
368
+ const read = async (p, a, b) => {
369
+ assertScope();
370
+ const args = normalizeRead(gatherReadArgs(p, a, b));
371
+ const decode = value => decodeReadValue(args, value);
372
+ p = args.path;
360
373
 
361
- if (newText !== undefined) args.oldText = oldText;
374
+ if (Array.isArray(p)) {
375
+ const values = await readManyPaths(read, invoke, batchRead, args, p, decode);
376
+ for (const item of p) if (isString(item)) noteReadPath({ path: item }, values);
362
377
 
363
- return unwrapValue(await invoke("edit", args));
378
+ return values;
364
379
  }
365
380
 
366
- if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
367
-
368
- if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
369
- const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
381
+ const readValue = !batchRead
382
+ ? decode(unwrapRead(await invoke("read", args), args))
383
+ : await enqueueCompatibleRead(readState, flushReads, args, decode);
384
+ noteReadPath(args, readValue);
370
385
 
371
- if (!isString(args.path) || !args.path.trim()) throw new Error(usage);
372
- const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
386
+ return readValue;
387
+ };
373
388
 
374
- if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(usage);
389
+ const readFiles = new Set();
375
390
 
376
- if (args.patch !== undefined) {
377
- if (!isString(args.patch) || !args.patch.trim()) throw new Error(usage);
378
- } else {
379
- const edits = args.edits === undefined ? [args] : args.edits;
391
+ function noteReadPath(args, value) {
392
+ if (isString(args.path) && looksLikePath(args.path)) readFiles.add(args.path);
393
+ if (isObject(value) && isString(value.path) && looksLikePath(value.path)) readFiles.add(value.path);
394
+ }
380
395
 
381
- if (!Array.isArray(edits) || !edits.length) throw new Error(usage);
396
+ const write = async (p, content) => {
397
+ const args = isObject(p) ? p : { path: p, content };
382
398
 
383
- for (const e of edits) if (!isString(e?.oldText) || !e.oldText.length || !isString(e?.newText)) throw new Error(usage + "; replacements require non-empty oldText and string newText");
399
+ if (args.append !== true && args.replace !== true && isString(args.path) && readFiles.has(args.path)) {
400
+ throw new Error("file was already read this program; use edit(oldText, newText) or edit(view, ...). write({path,content,replace:true}) replaces anyway");
384
401
  }
385
402
 
386
- return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
403
+ return unwrapValue(await invoke("write", args));
387
404
  };
388
405
 
389
- const bash = async (command, opts) => {
390
- const args = isObject(command) ? { ...command } : { command, ...opts };
391
-
392
- if (args.args !== undefined) {
393
- if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
406
+ const edit = async (p, oldText, newText) => {
407
+ const classified = classifyEdit(p, oldText, newText);
394
408
 
395
- for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error("bash argv requires a command string and an array of string args");
396
- args.args = args.args.map(String);
409
+ if (classified.kind === "checkpoint") return nova.speculate(classified.fn);
397
410
 
398
- // Literal argv is a Supernova-owned contract. Host bash tools often ignore
399
- // `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
400
- if (process.platform === "win32") {
401
- delete args._directArgv;
402
- args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
403
- delete args.args;
404
- } else args._directArgv = true;
405
- }
411
+ return unwrapValue(await invoke(classified.command, classified.args));
412
+ };
406
413
 
414
+ const bash = async (command, opts) => {
415
+ const args = normalizeBash(command, opts);
407
416
  command = args.command;
408
-
409
- if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
410
417
  const res = await invoke("bash", args);
411
418
 
412
- if (res?.ok === false) {
413
- let exitCode;
414
-
415
- try {
416
- exitCode = JSON.parse(res.details).exitCode;
417
- } catch {}
418
-
419
- const output = String(res.value).trimEnd();
420
- const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
421
- throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
422
- }
423
-
424
- let text = unwrapValue(res);
425
-
426
- if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
419
+ if (res?.ok === false) throw new Error(formatBashFailure(command, res));
427
420
 
428
- return text;
421
+ return markTruncatedOutput(res, unwrapValue(res));
429
422
  };
430
423
 
431
424
  return { read, write, edit, bash, nova };
@@ -446,22 +439,7 @@ function makeConsole(runId, limits) {
446
439
  return; }
447
440
 
448
441
  count++;
449
-
450
- const line = args
451
- .map((a) => {
452
- if (isString(a)) return a;
453
-
454
- try {
455
- const plain = toPlain(a);
456
- const encoded = JSON.stringify(plain);
457
-
458
- return encoded === undefined ? String(plain) : encoded;
459
- } catch {
460
- return String(a);
461
- }
462
- })
463
- .join(" ");
464
-
442
+ const line = args.map(encodeConsoleArg).join(" ");
465
443
  const clipped = truncateChars(line, limits.maxLogLineChars, "log");
466
444
 
467
445
  if (clipped.truncated) markTruncated();
@@ -477,16 +455,55 @@ function postFailure(runId, err, location) {
477
455
  post({ op: "error", runId, message, location });
478
456
  }
479
457
 
458
+ function denyBuiltin(id) {
459
+ if (isDeniedGuestImport(id)) throw new Error(guestImportMessage(id));
460
+ }
461
+
462
+ let sealedRealm = false;
463
+
464
+ function sealGuestRealm() {
465
+ if (sealedRealm) return;
466
+ sealedRealm = true;
467
+ if (isFunction(process.getBuiltinModule)) {
468
+ const orig = process.getBuiltinModule.bind(process);
469
+ process.getBuiltinModule = (id) => {
470
+ denyBuiltin(id);
471
+
472
+ return orig(id);
473
+ };
474
+ }
475
+
476
+ if (isFunction(process.binding)) {
477
+ process.binding = (id) => {
478
+ throw new Error(guestImportMessage(String(id)));
479
+ };
480
+ }
481
+
482
+ if (isFunction(process.dlopen)) {
483
+ process.dlopen = () => {
484
+ throw new Error("guest cannot load native modules; use read, edit, write, or bash");
485
+ };
486
+ }
487
+
488
+ if (isFunction(process.kill)) {
489
+ // Signals are process-wide: process.kill escapes the worker thread and can
490
+ // terminate the host, so it stays out of the guest. Stop things with bash.
491
+ process.kill = () => {
492
+ throw new Error("process.kill is not available in guest programs; stop processes with bash");
493
+ };
494
+ }
495
+ }
496
+
480
497
  async function handleRun(msg) {
481
498
  const { runId, prepared, limits, available, batchRead = true } = msg;
482
499
  activeRunId = runId;
483
500
  runActive = true;
484
501
  let compiled;
502
+ sealGuestRealm();
485
503
 
486
504
  try {
487
505
  // Existing programs may declare their own data variable; bind it only when supplied.
488
- const bindings = msg.data === undefined ? PARAMS : [...PARAMS, "data"];
489
- compiled = { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
506
+ compiled = compileGuest(prepared, msg.data);
490
507
  } catch (err) {
491
508
  postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran. When passing data, do not redeclare its binding."));
492
509
 
@@ -502,14 +519,7 @@ async function handleRun(msg) {
502
519
  );
503
520
 
504
521
  if (runId !== activeRunId) return;
505
- let plain;
506
-
507
- try {
508
- plain = toPlain(value);
509
- } catch (err) {
510
- plain = "[unserializable: " + (err?.message || err) + "]";
511
- }
512
-
522
+ const plain = plainGuestValue(value);
513
523
  runActive = false;
514
524
  post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
515
525
  } catch (err) {
@@ -522,13 +532,7 @@ parentPort.on("message", (msg) => {
522
532
  if (!isObject(msg)) return;
523
533
 
524
534
  if (msg.op === "rpc:result") {
525
- const pending = pendingRpc.get(msg.id);
526
-
527
- if (!pending) return;
528
- pendingRpc.delete(msg.id);
529
-
530
- if (msg.ok) pending.resolve(msg.value);
531
- else pending.reject(new Error(msg.error));
535
+ handleRpcResult(msg);
532
536
 
533
537
  return;
534
538
  }