pi-supernova 0.5.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 (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  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 +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  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 +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -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,248 +108,313 @@ function leanEnvelope(res) {
94
108
  return res;
95
109
  }
96
110
 
97
- function quoteShellArg(value) {
98
- return "'" + String(value).replaceAll("'", "'\\''") + "'";
111
+ function swallow(promise) {
112
+ promise.catch(() => {});
113
+
114
+ return promise;
99
115
  }
100
116
 
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;
117
+ function throwReadPathError(target, detail) {
118
+ throw new Error(`read failed for ${target}: ${detail}${PER_PATH_HINT}`);
119
+ }
106
120
 
107
- const assertScope = () => {
108
- const token = checkpointScope.getStore();
121
+ function isQueryUri(item) {
122
+ return QUERY_URI.test(item);
123
+ }
109
124
 
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
- };
125
+ function firstItemErrorIndex(res) {
126
+ return res?.itemErrors?.findIndex(error => error != null) ?? -1;
127
+ }
112
128
 
113
- const nova = {
114
- call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
115
- async callMany(calls) {
116
- const wave = await rpc("callMany", [calls]);
117
- const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
118
- Object.defineProperties(results, {
119
- mode: { value: wave?.mode, enumerable: false },
120
- reason: { value: wave?.reason, enumerable: false },
121
- results: { value: results, enumerable: false },
122
- });
123
-
124
- return results;
125
- },
126
- async speculate(fn) {
127
- if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
128
- const token = {};
129
- checkpoint = token;
130
- let began = false;
129
+ function missingResolvedIndex(values, paths) {
130
+ return values.findIndex((value, index) => value?.status === "not_found" && looksLikePath(paths[index]));
131
+ }
131
132
 
132
- try {
133
- flushReads();
134
- await rpc("speculateBegin", []);
135
- began = true;
136
- const value = await checkpointScope.run(token, fn);
137
- flushReads();
138
- await rpc("speculateCommit", []);
133
+ async function rpcReadWave(rpc, wave) {
134
+ if (wave.length === 1) {
135
+ const res = leanEnvelope(await rpc("call", ["read", wave[0].args]));
139
136
 
140
- return { ok: true, committed: true, value };
141
- } catch (err) {
142
- flushReads();
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);
143
142
 
144
- if (began) await rpc("speculateRollback", []);
143
+ return { values: res.items, errors: res.itemErrors ?? [] };
144
+ }
145
145
 
146
- return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
147
- } finally { checkpoint = null; }
148
- },
149
- surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
150
- evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
151
- snap: async (query, targetPath) => unwrapJsonValue(await rpc("call", ["snap", { query, path: targetPath }])),
152
- has: (name) => availableSet.has(name),
146
+ function settleReadWave(wave, values, errors) {
147
+ if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
148
+
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
+ }
154
+
155
+ function rejectReadWave(wave, error) {
156
+ for (const job of wave) job.reject(error);
157
+ }
158
+
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));
165
+
166
+ pendingReadWaves.add(delivery);
167
+ void delivery.finally(() => pendingReadWaves.delete(delivery));
168
+ }
169
+ }
170
+
171
+ function enqueueCompatibleRead(readState, flushReads, args, decode) {
172
+ const key = JSON.stringify({ ...args, path: undefined });
173
+
174
+ if (readState.queued.length && readState.queued[0].key !== key) flushReads();
175
+
176
+ const promise = new Promise((resolve, reject) => {
177
+ readState.queued.push({ args, key, resolve, reject });
178
+
179
+ if (readState.queued.length === 1) queueMicrotask(flushReads);
180
+ }).then(decode);
181
+
182
+ return swallow(promise);
183
+ }
184
+
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;
190
+
191
+ if (missing >= 0) throwReadPathError(paths[missing], "not_found");
192
+
193
+ return values;
153
194
  };
154
195
 
155
- // Coalesce already-started compatible reads without rewriting JS control flow.
156
- let queuedReads = [];
196
+ const guardedReadEach = () => swallow(readEach());
157
197
 
158
- function flushReads() {
159
- const pending = queuedReads;
160
- queuedReads = [];
198
+ if (!batchRead || args.resolve || paths.some(isQueryUri)) return guardedReadEach();
199
+ const res = await invoke("read", args);
200
+ const failed = firstItemErrorIndex(res);
161
201
 
162
- for (let start = 0; start < pending.length; start += 64) {
163
- const wave = pending.slice(start, start + 64);
164
- const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
202
+ if (failed >= 0) throwReadPathError(paths[failed], res.itemErrors[failed]);
203
+ unwrapRead(res, args);
165
204
 
166
- const run = wave.length === 1
167
- ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
168
- : nova.call("read", args).then(res => { unwrapRead(res, args);
205
+ if (Array.isArray(res?.items)) return res.items.map(decode);
169
206
 
170
- return { values: res.items, errors: res.itemErrors ?? [] }; });
207
+ return guardedReadEach();
208
+ }
171
209
 
172
- void run.then(({ values, errors }) => {
173
- if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
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
+ });
174
217
 
175
- for (let i = 0; i < wave.length; i++) {
176
- const value = values[i];
218
+ return results;
219
+ }
177
220
 
178
- if (errors[i]) wave[i].reject(new Error(errors[i]));
179
- else wave[i].resolve(value);
180
- }
181
- }).catch(error => { for (const job of wave) job.reject(error); });
182
- }
183
- }
221
+ async function runSpeculation(fn, token, checkpointScope, drainReads, enqueueHost, rpc) {
222
+ let began = false;
184
223
 
185
- const invoke = (name, args) => { assertScope(); flushReads();
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();
186
235
 
187
- return nova.call(name, args); };
236
+ if (began) await enqueueHost(() => rpc("speculateRollback", []));
188
237
 
189
- const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
190
- ? { ...p, path: p.path ?? p.query }
191
- : isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
238
+ return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
239
+ }
240
+ }
192
241
 
193
- const read = async (p, a, b) => {
194
- assertScope();
195
- const args = sessionJsonArgs(readArgs(p, a, b));
196
- if (isString(args.path) && args.resolve === undefined && args.json === undefined && !looksLikePath(args.path) && !/^(?:agent|artifact):\/\//i.test(args.path)) {
197
- args.resolve = true;
198
- }
199
- validateJsonRead(args);
200
- const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
242
+ function formatBashFailure(command, res) {
243
+ let exitCode;
201
244
 
202
- if (args.complete === true && (args.outline || args.evidence || args.about)) throw new Error("complete:true requires a raw file read, not an outline or evidence view");
203
- const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
204
- p = args.path;
245
+ try {
246
+ exitCode = JSON.parse(res.details).exitCode;
247
+ } catch {}
205
248
 
206
- if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
249
+ const output = String(res.value).trimEnd();
250
+ const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
207
251
 
208
- if (args.outline) return unwrapJsonValue(await invoke("surface", args));
252
+ return "command failed" + suffix + ": " + command + (output ? "\n" + output : "");
253
+ }
209
254
 
210
- if (Array.isArray(p)) {
211
- if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
255
+ function markTruncatedOutput(res, text) {
256
+ if (res?.truncated && isString(text) && !text.includes("truncated")) return text + "\n…[output truncated]…";
212
257
 
213
- for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
214
- const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
258
+ return text;
259
+ }
260
+
261
+ function encodeConsoleArg(a) {
262
+ if (isString(a)) return a;
263
+
264
+ try {
265
+ const plain = toPlain(a);
266
+ const encoded = JSON.stringify(plain);
215
267
 
216
- if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
217
- const res = await invoke("read", args);
218
- const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
268
+ return encoded === undefined ? String(plain) : encoded;
269
+ } catch {
270
+ return String(a);
271
+ }
272
+ }
219
273
 
220
- 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`);
221
- unwrapRead(res, args);
274
+ function compileGuest(prepared, data) {
275
+ const bindings = data === undefined ? PARAMS : [...PARAMS, "data"];
222
276
 
223
- if (Array.isArray(res?.items)) return res.items.map(decode);
277
+ return { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
278
+ }
224
279
 
225
- // Captured host executor without batch support: fan out.
226
- return readEach();
227
- }
280
+ function plainGuestValue(value) {
281
+ try { return toPlain(value); }
282
+ catch (err) { return "[unserializable: " + (err?.message || err) + "]"; }
283
+ }
228
284
 
229
- if (!batchRead) {
230
- const res = await invoke("read", args);
231
- unwrapRead(res, args);
285
+ function handleRpcResult(msg) {
286
+ const pending = pendingRpc.get(msg.id);
232
287
 
233
- return decode(unwrapRead(res, args));
234
- }
288
+ if (!pending) return;
289
+ pendingRpc.delete(msg.id);
235
290
 
236
- const key = JSON.stringify({ ...args, path: undefined });
291
+ if (msg.ok) pending.resolve(msg.value);
292
+ else pending.reject(new Error(msg.error));
293
+ }
237
294
 
238
- if (queuedReads.length && queuedReads[0].key !== key) flushReads();
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;
239
299
 
240
- return new Promise((resolve, reject) => {
241
- queuedReads.push({ args, key, resolve, reject });
300
+ const assertScope = () => {
301
+ const token = checkpointScope.getStore();
242
302
 
243
- if (queuedReads.length === 1) queueMicrotask(flushReads);
244
- }).then(decode);
303
+ if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
245
304
  };
246
305
 
247
- const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
306
+ let operationTail = Promise.resolve();
248
307
 
249
- const viewSpan = (value) => {
250
- const start = isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
251
- const end = isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
308
+ const enqueueHost = operation => {
309
+ const next = operationTail.then(operation);
310
+ operationTail = next.then(() => {}, () => {});
252
311
 
253
- if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
254
-
255
- return { start: Math.floor(start), end: Math.floor(end) };
312
+ return next;
256
313
  };
257
314
 
258
- 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);
315
+ const nova = {
316
+ call(name, args) {
317
+ assertScope(); flushReads();
259
318
 
260
- const edit = async (p, oldText, newText) => {
261
- if (isFunction(p)) return nova.speculate(p);
262
- 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"})';
319
+ return swallow(enqueueHost(() => rpc("call", [name, args]).then(leanEnvelope)));
320
+ },
321
+ callMany(calls) {
322
+ assertScope(); flushReads();
263
323
 
264
- if (isView(p) && isString(oldText) && (newText === undefined || isString(newText))) {
265
- if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
266
- const span = viewSpan(p);
267
- const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
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;
268
330
 
269
- if (newText !== undefined) args.oldText = oldText;
331
+ try { return await runSpeculation(fn, token, checkpointScope, drainReads, enqueueHost, rpc); }
332
+ finally { checkpoint = null; }
333
+ },
334
+ };
270
335
 
271
- return unwrapValue(await invoke("edit", args));
272
- }
336
+ // Coalesce already-started compatible reads without rewriting JS control flow.
337
+ const readState = { queued: [], waves: new Set() };
273
338
 
274
- if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
339
+ function flushReads() {
340
+ const pending = readState.queued;
341
+ readState.queued = [];
342
+ dispatchReadWaves(pending, readState.waves, enqueueHost, rpc);
343
+ }
275
344
 
276
- if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
277
- const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
345
+ async function drainReads() {
346
+ for (;;) {
347
+ flushReads();
348
+ const waves = [...readState.waves];
278
349
 
279
- if (!isString(args.path) || !args.path.trim()) throw new Error(usage);
280
- const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
350
+ if (!waves.length) return;
351
+ await Promise.allSettled(waves);
352
+ }
353
+ }
281
354
 
282
- if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(usage);
355
+ const invoke = (name, args) => {
356
+ assertScope(); flushReads();
283
357
 
284
- if (args.patch !== undefined) {
285
- if (!isString(args.patch) || !args.patch.trim()) throw new Error(usage);
286
- } else {
287
- const edits = args.edits === undefined ? [args] : args.edits;
358
+ return swallow(nova.call(name, args));
359
+ };
360
+
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;
288
366
 
289
- if (!Array.isArray(edits) || !edits.length) throw new Error(usage);
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);
290
370
 
291
- 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");
371
+ return values;
292
372
  }
293
373
 
294
- return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
374
+ const readValue = !batchRead
375
+ ? decode(unwrapRead(await invoke("read", args), args))
376
+ : await enqueueCompatibleRead(readState, flushReads, args, decode);
377
+ noteReadPath(args, readValue);
378
+
379
+ return readValue;
295
380
  };
296
381
 
297
- const bash = async (command, opts) => {
298
- const args = isObject(command) ? { ...command } : { command, ...opts };
382
+ const readFiles = new Set();
299
383
 
300
- if (args.args !== undefined) {
301
- if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
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
+ }
302
388
 
303
- 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");
389
+ const write = async (p, content) => {
390
+ const args = isObject(p) ? p : { path: p, content };
304
391
 
305
- // Literal argv is a Supernova-owned contract. Host bash tools often ignore
306
- // `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
307
- if (process.platform === "win32") {
308
- delete args._directArgv;
309
- args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
310
- delete args.args;
311
- } else args._directArgv = true;
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");
312
394
  }
313
395
 
314
- command = args.command;
315
-
316
- if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
317
- const res = await invoke("bash", args);
396
+ return unwrapValue(await invoke("write", args));
397
+ };
318
398
 
319
- if (res?.ok === false) {
320
- let exitCode;
399
+ const edit = async (p, oldText, newText) => {
400
+ const classified = classifyEdit(p, oldText, newText);
321
401
 
322
- try {
323
- exitCode = JSON.parse(res.details).exitCode;
324
- } catch {}
402
+ if (classified.kind === "checkpoint") return nova.speculate(classified.fn);
325
403
 
326
- const output = String(res.value).trimEnd();
327
- const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
328
- throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
329
- }
404
+ return unwrapValue(await invoke(classified.command, classified.args));
405
+ };
330
406
 
331
- let text = unwrapValue(res);
407
+ const bash = async (command, opts) => {
408
+ const args = normalizeBash(command, opts);
409
+ command = args.command;
410
+ const res = await invoke("bash", args);
332
411
 
333
- if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
412
+ if (res?.ok === false) throw new Error(formatBashFailure(command, res));
334
413
 
335
- return text;
414
+ return markTruncatedOutput(res, unwrapValue(res));
336
415
  };
337
416
 
338
- return { read, write, edit, bash };
417
+ return { read, write, edit, bash, nova };
339
418
  }
340
419
 
341
420
  function makeConsole(runId, limits) {
@@ -353,19 +432,7 @@ function makeConsole(runId, limits) {
353
432
  return; }
354
433
 
355
434
  count++;
356
-
357
- const line = args
358
- .map((a) => {
359
- if (isString(a)) return a;
360
-
361
- try {
362
- return JSON.stringify(toPlain(a));
363
- } catch {
364
- return String(a);
365
- }
366
- })
367
- .join(" ");
368
-
435
+ const line = args.map(encodeConsoleArg).join(" ");
369
436
  const clipped = truncateChars(line, limits.maxLogLineChars, "log");
370
437
 
371
438
  if (clipped.truncated) markTruncated();
@@ -381,16 +448,55 @@ function postFailure(runId, err, location) {
381
448
  post({ op: "error", runId, message, location });
382
449
  }
383
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
+
384
490
  async function handleRun(msg) {
385
491
  const { runId, prepared, limits, available, batchRead = true } = msg;
386
492
  activeRunId = runId;
387
493
  runActive = true;
388
494
  let compiled;
495
+ sealGuestRealm();
389
496
 
390
497
  try {
391
498
  // Existing programs may declare their own data variable; bind it only when supplied.
392
- const bindings = msg.data === undefined ? PARAMS : [...PARAMS, "data"];
393
- compiled = { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
499
+ compiled = compileGuest(prepared, msg.data);
394
500
  } catch (err) {
395
501
  postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran. When passing data, do not redeclare its binding."));
396
502
 
@@ -406,14 +512,7 @@ async function handleRun(msg) {
406
512
  );
407
513
 
408
514
  if (runId !== activeRunId) return;
409
- let plain;
410
-
411
- try {
412
- plain = toPlain(value);
413
- } catch (err) {
414
- plain = "[unserializable: " + (err?.message || err) + "]";
415
- }
416
-
515
+ const plain = plainGuestValue(value);
417
516
  runActive = false;
418
517
  post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
419
518
  } catch (err) {
@@ -426,13 +525,7 @@ parentPort.on("message", (msg) => {
426
525
  if (!isObject(msg)) return;
427
526
 
428
527
  if (msg.op === "rpc:result") {
429
- const pending = pendingRpc.get(msg.id);
430
-
431
- if (!pending) return;
432
- pendingRpc.delete(msg.id);
433
-
434
- if (msg.ok) pending.resolve(msg.value);
435
- else pending.reject(new Error(msg.error));
528
+ handleRpcResult(msg);
436
529
 
437
530
  return;
438
531
  }