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