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
@@ -5,6 +5,7 @@ import { performance } from "node:perf_hooks";
5
5
  import { packageFinalReturn } from "../output/bottleneck.js";
6
6
  import { truncateChars } from "../output/format.js";
7
7
  import { isFunction, isObject, isString } from "../shared/decode.js";
8
+ import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
8
9
 
9
10
  const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
10
11
 
@@ -16,6 +17,25 @@ const MEMORY_SLACK = 1.5;
16
17
 
17
18
  const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
18
19
 
20
+ const toMb = bytes => Math.max(0, bytes / 1048576);
21
+
22
+ /** Pure attribution for a memory-limit trip: which operation, and how much of the RSS growth supernova can account for. */
23
+ export function formatMemoryAttribution({ limitMb, rssBytes: now, startBytes, ms, op, calls, tracked }) {
24
+ const deltaMb = toMb(now - startBytes);
25
+ const parts = tracked
26
+ ? [["vfs cache", tracked.vfsCacheBytes], ["index entries", tracked.indexBytes], ["overlays", tracked.overlayBytes]]
27
+ : [];
28
+ const trackedMb = parts.reduce((sum, [, bytes]) => sum + toMb(Number(bytes) || 0), 0);
29
+ const untrackedMb = Math.max(0, deltaMb - trackedMb);
30
+ const where = op ? ` during ${op} (${calls} host calls)` : ` (${calls} host calls)`;
31
+ const seen = parts.length
32
+ ? `; supernova-tracked host bytes: ${parts.map(([name, bytes]) => `${name} ${toMb(Number(bytes) || 0).toFixed(1)}MB`).join(", ")} in ${tracked.overlayFiles ?? 0} overlay files`
33
+ : "";
34
+
35
+ return `guest exceeded memory limit (maxHeapMb=${limitMb}): process RSS +${deltaMb.toFixed(1)}MB in ${Math.round(ms)}ms${where}${seen}; `
36
+ + `~${untrackedMb.toFixed(1)}MB untracked (worker heap, transient buffers, or host/concurrent growth outside supernova)`;
37
+ }
38
+
19
39
  let idleWorker = null;
20
40
 
21
41
  let runSeq = 0;
@@ -34,37 +54,65 @@ function hasReturn(node) {
34
54
  return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
35
55
  }
36
56
 
37
- function prepareProgram(code) {
38
- let program;
39
- let expression;
40
- let expressionSource;
41
-
57
+ function parseExpressionFunction(code) {
42
58
  try {
43
- program = parse(code, PARSE_OPTIONS);
59
+ const program = parse(code, PARSE_OPTIONS);
44
60
  const statements = program.body.filter(node => node.type !== "EmptyStatement");
45
61
  const statement = statements.length === 1 ? statements[0] : undefined;
46
62
  const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
47
63
 
48
64
  if (candidate && FUNCTION_TYPES.has(candidate.type)) {
49
- expression = candidate;
50
- expressionSource = code.slice(statement.start, statement.end).replace(/;\s*$/, "");
65
+ return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
51
66
  }
67
+
68
+ return { program };
52
69
  } catch (bodyError) {
53
- expressionSource = code.trimEnd().replace(/;+\s*$/, "");
70
+ const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
54
71
 
55
72
  try {
56
73
  const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
57
- expression = wrapped.body[0]?.expression;
74
+ const expression = wrapped.body[0]?.expression;
58
75
 
59
76
  if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
77
+
78
+ return { expression, expressionSource };
60
79
  } catch { throw bodyError; }
61
80
  }
81
+ }
82
+
83
+ function deniedSpecifier(node) {
84
+ if (node?.type === "Literal" && isString(node.value)) return node.value;
85
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
86
+ }
87
+
88
+ function assertGuestImports(ast) {
89
+ function walk(node) {
90
+ if (Array.isArray(node)) { node.forEach(walk); return; }
91
+ if (!isObject(node)) return;
92
+ if (node.type === "ImportDeclaration" || node.type === "ImportExpression") {
93
+ const spec = deniedSpecifier(node.source);
94
+ throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
95
+ }
96
+ if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") {
97
+ const spec = deniedSpecifier(node.arguments?.[0]);
98
+ throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
99
+ }
100
+ for (const key of Object.keys(node)) {
101
+ if (key === "start" || key === "end" || key === "loc" || key === "range") continue;
102
+ walk(node[key]);
103
+ }
104
+ }
62
105
 
63
- const body = expression ? "return await (" + expressionSource + "\n)();" : code;
106
+ walk(ast);
107
+ }
64
108
 
65
- const returns = expression
66
- ? expression.type === "ArrowFunctionExpression" && expression.body.type !== "BlockStatement" || hasReturn(expression.body)
67
- : hasReturn(program);
109
+ function prepareProgram(code) {
110
+ const parsed = parseExpressionFunction(code);
111
+ assertGuestImports(parsed.program ?? parsed.expression);
112
+ const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
113
+ const returns = parsed.expression
114
+ ? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
115
+ : hasReturn(parsed.program);
68
116
 
69
117
  return { body, hasReturn: returns };
70
118
  }
@@ -128,6 +176,13 @@ function acquireWorker(config) {
128
176
  const handle = reusable ? candidate : spawnWorker(config);
129
177
  handle.worker.ref?.();
130
178
 
179
+ // Pipeline the successor while this run executes. A consumed worker's
180
+ // replacement starts at once; a cold start's replacement waits for ready,
181
+ // so the two constructions never overlap. The finish-time warm usually
182
+ // becomes a no-op, so steady-state spawn count is unchanged.
183
+ if (reusable) warmGuestWorker(config).catch(() => {});
184
+ else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
185
+
131
186
  return handle;
132
187
  }
133
188
 
@@ -159,214 +214,299 @@ const RPC_METHODS = {
159
214
  speculateRollback: (nova) => nova.speculateRollback(),
160
215
  };
161
216
 
162
- export async function runGuestProgram({ code, file, cwd = process.cwd(), data, nova = {}, config = {}, signal, onTimeout }) {
163
- const started = performance.now();
164
- const wall = () => Math.round(performance.now() - started);
165
- const logs = [];
166
- const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
167
- let logTruncated = false;
217
+ function admitData(data, cap) {
218
+ if (data === undefined) return { data };
219
+
220
+ try {
221
+ const encoded = JSON.stringify(data);
168
222
 
169
- if ((code === undefined) === (file === undefined)) return fail("supply exactly one of code or file; no commands ran");
223
+ if (encoded === undefined) return { error: "data must be JSON-serializable" };
224
+ if (encoded.length > cap) return { error: "data exceeds " + cap + " characters; split literal inputs across invocations" };
170
225
 
171
- if (file === undefined && (!isString(code) || !code.trim())) return fail("code must be a non-empty string");
226
+ return { data: JSON.parse(encoded) };
227
+ } catch { return { error: "data must be JSON-serializable" }; }
228
+ }
172
229
 
173
- if (file === undefined && code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters; split large writes into write({path,content,append:true}) chunks");
230
+ function admitCode({ code, file, cap }) {
231
+ if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
232
+ if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
233
+ if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
234
+ }
174
235
 
175
- if (data !== undefined) {
176
- try {
177
- const encoded = JSON.stringify(data);
236
+ function admitTimeout(config) {
237
+ const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
178
238
 
179
- if (encoded === undefined) return fail("data must be JSON-serializable");
239
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
180
240
 
181
- if (encoded.length > (config.maxCodeChars ?? 48000)) return fail("data exceeds " + (config.maxCodeChars ?? 48000) + " characters; split literal inputs across invocations");
182
- data = JSON.parse(encoded);
183
- } catch { return fail("data must be JSON-serializable"); }
184
- }
241
+ return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
242
+ }
185
243
 
186
- if (signal?.aborted) return fail(ABORT_MESSAGE);
187
- const runId = ++runSeq;
188
- const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
244
+ function admitGuest({ code, file, data, config }) {
245
+ const cap = config.maxCodeChars ?? 48000;
246
+ const codeError = admitCode({ code, file, cap });
189
247
 
190
- if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return fail("timeoutMs must be a positive finite number");
191
- const timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
192
- const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
193
-
194
- return new Promise((resolve) => {
195
- let handle;
196
- let finished = false;
197
- let accepting = true;
198
- let completing = false;
199
- let hostError;
200
- let notifyingHost = false;
201
- let aborting = false;
202
- const pending = new Set();
203
- const inputController = new AbortController();
248
+ if (codeError) return codeError;
249
+ const admitted = admitData(data, cap);
204
250
 
205
- const cleanup = () => {
206
- inputController.abort();
207
- clearTimeout(timer);
208
- clearInterval(memTimer);
209
- signal?.removeEventListener("abort", signalAbort);
210
- handle?.worker.off("message", onMessage);
211
- handle?.worker.off("error", onError);
212
- handle?.worker.off("exit", onExit);
213
- };
251
+ if (admitted.error) return admitted;
252
+ const timeout = admitTimeout(config);
214
253
 
215
- const finish = (outcome) => {
216
- if (finished) return;
217
- finished = true;
218
- accepting = false;
219
- cleanup();
220
- void killWorker(handle);
221
- resolve({ ...outcome, wallMs: wall() });
222
- };
254
+ if (timeout.error) return timeout;
223
255
 
224
- const cancelHost = () => {
225
- notifyingHost = true;
256
+ return { data: admitted.data, timeoutMs: timeout.timeoutMs };
257
+ }
226
258
 
227
- try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
228
- };
259
+ class GuestRun {
260
+ constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs, rssLimit, rssStart }) {
261
+ this.code = code;
262
+ this.file = file;
263
+ this.cwd = cwd;
264
+ this.data = data;
265
+ this.nova = nova;
266
+ this.config = config;
267
+ this.signal = signal;
268
+ this.onTimeout = onTimeout;
269
+ this.runId = runId;
270
+ this.timeoutMs = timeoutMs;
271
+ this.rssLimit = rssLimit;
272
+ this.rssStart = rssStart;
273
+ this.started = performance.now();
274
+ this.logs = [];
275
+ this.logTruncated = false;
276
+ this.handle = undefined;
277
+ this.finished = false;
278
+ this.accepting = true;
279
+ this.completing = false;
280
+ this.hostError = undefined;
281
+ this.notifyingHost = false;
282
+ this.aborting = false;
283
+ this.pending = new Set();
284
+ this.inputController = new AbortController();
285
+ this.rpcCount = 0;
286
+ this.lastRpcMethod = null;
287
+ }
229
288
 
230
- const abort = () => {
231
- if (finished || aborting) return;
232
- aborting = true;
233
- cancelHost();
289
+ wall() {
290
+ return Math.round(performance.now() - this.started);
291
+ }
234
292
 
235
- try { onTimeout?.(); } catch {}
293
+ fail(error) {
294
+ return { ok: false, error: truncateChars(String(error), this.config.maxReturnChars ?? 32000, "error").text, logs: this.logs, logTruncated: this.logTruncated, wallMs: this.wall() };
295
+ }
236
296
 
237
- aborting = false;
238
- finish(fail(ABORT_MESSAGE));
239
- };
297
+ cleanup() {
298
+ this.inputController.abort();
299
+ clearTimeout(this.timer);
300
+ clearInterval(this.memTimer);
301
+ this.signal?.removeEventListener("abort", this.signalAbort);
302
+ this.handle?.worker.off("message", this.onMessage);
303
+ this.handle?.worker.off("error", this.onError);
304
+ this.handle?.worker.off("exit", this.onExit);
305
+ }
306
+
307
+ finish(outcome) {
308
+ if (this.finished) return;
309
+ this.finished = true;
310
+ this.accepting = false;
311
+ this.cleanup();
312
+ void killWorker(this.handle);
313
+ this.resolve({ ...outcome, wallMs: this.wall() });
314
+ }
240
315
 
241
- const signalAbort = () => { if (!notifyingHost) abort(); };
316
+ cancelHost() {
317
+ this.notifyingHost = true;
242
318
 
243
- const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
319
+ try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
320
+ }
244
321
 
245
- const memTimer = setInterval(() => {
246
- if (rssBytes() <= rssLimit) return;
247
- cancelHost();
248
- finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
249
- }, MEMORY_POLL_MS);
322
+ abort() {
323
+ if (this.finished || this.aborting) return;
324
+ this.aborting = true;
325
+ this.cancelHost();
250
326
 
251
- signal?.addEventListener("abort", signalAbort, { once: true });
327
+ try { this.onTimeout?.(); } catch {}
252
328
 
253
- const postResult = (message) => {
254
- if (!accepting || finished) return;
329
+ this.aborting = false;
330
+ this.finish(this.fail(ABORT_MESSAGE));
331
+ }
255
332
 
333
+ postResult(message) {
334
+ if (!this.accepting || this.finished) return;
335
+
336
+ try {
337
+ this.handle.worker.postMessage({ ...message, runId: this.runId });
338
+ } catch (err) {
256
339
  try {
257
- handle.worker.postMessage({ ...message, runId });
258
- } catch (err) {
259
- try {
260
- handle.worker.postMessage({ op: "rpc:result", id: message.id, runId, ok: false, error: "result not transferable: " + err.message });
261
- } catch (error) {
262
- onError(error);
263
- }
340
+ this.handle.worker.postMessage({ op: "rpc:result", id: message.id, runId: this.runId, ok: false, error: "result not transferable: " + err.message });
341
+ } catch (error) {
342
+ this.onError(error);
264
343
  }
265
- };
344
+ }
345
+ }
266
346
 
267
- const complete = async (outcome) => {
268
- if (finished || completing) return;
269
- completing = true;
270
- accepting = false;
271
- // Stop timers and detached guest continuations before settling host work.
272
- handle?.worker.off("error", onError);
273
- handle?.worker.off("exit", onExit);
274
- void killWorker(handle);
275
-
276
- if (pending.size || !outcome.ok) cancelHost();
277
- // A host tool that ignores cancellation must not keep the result unsettled
278
- // after the guest worker is gone. Give it a brief drain window only.
279
- await Promise.race([Promise.allSettled(pending), new Promise(resolve => setTimeout(resolve, 250))]);
280
- if (pending.size && outcome.ok) hostError ??= "program completed with a host call still running";
281
-
282
- if (finished) return;
283
- finish(outcome.ok && hostError ? fail(hostError) : outcome);
284
- };
347
+ async drainPending(outcome) {
348
+ if (this.pending.size || !outcome.ok) this.cancelHost();
349
+ await Promise.race([Promise.allSettled(this.pending), new Promise(resolve => setTimeout(resolve, 250))]);
350
+ if (this.pending.size && outcome.ok) this.hostError ??= "program completed with a host call still running";
351
+ }
285
352
 
286
- const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
353
+ async complete(outcome) {
354
+ if (this.finished || this.completing) return;
355
+ this.completing = true;
356
+ this.accepting = false;
357
+ this.handle?.worker.off("error", this.onError);
358
+ this.handle?.worker.off("exit", this.onExit);
359
+ void killWorker(this.handle);
360
+ await this.drainPending(outcome);
361
+ if (this.finished) return;
362
+ this.finish(outcome.ok && this.hostError ? this.fail(this.hostError) : outcome);
363
+ }
287
364
 
288
- const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
365
+ onError = (err) => { void this.complete(this.fail("guest crashed: " + err.message)); };
289
366
 
290
- const onMessage = (msg) => {
291
- if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
292
-
293
- if (msg.op === "log") {
294
- if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
295
- else logTruncated = true;
296
- logTruncated ||= msg.truncated === true;
297
- } else if (msg.op === "logTruncated") {
298
- logTruncated = true;
299
- } else if (msg.op === "rpc") {
300
- const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
301
-
302
- const work = Promise.resolve().then(() => {
303
- if (!method) throw new Error("unknown nova method: " + msg.method);
304
-
305
- return method(nova, msg.args);
306
- });
307
-
308
- pending.add(work);
309
- work.then(
310
- value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
311
- err => {
312
- // An awaited, handled host error must not poison the whole program.
313
- if (!accepting) hostError ??= err instanceof Error ? err.message : String(err);
314
- postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
315
- },
316
- ).finally(() => pending.delete(work));
317
- } else if (msg.op === "done") {
318
- try {
319
- const packed = packageFinalReturn(msg.value, logs, config);
320
- void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
321
- returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
322
- logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
323
- } catch (err) {
324
- void complete(fail(err.message));
325
- }
326
- } else if (msg.op === "error") {
327
- const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
328
- void complete(fail(msg.message + where));
329
- }
330
- };
367
+ onExit = (exitCode) => { void this.complete(this.fail("guest exited (code " + exitCode + ")")); };
331
368
 
332
- void (async () => {
333
- try {
334
- if (signal?.aborted) return abort();
369
+ onLog(msg) {
370
+ if (this.logs.length < (this.config.maxLogLines ?? 100)) this.logs.push(msg.line);
371
+ else this.logTruncated = true;
372
+ this.logTruncated ||= msg.truncated === true;
373
+ }
374
+
375
+ onRpc(msg) {
376
+ const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
377
+ this.rpcCount++;
378
+ this.lastRpcMethod = isString(msg.method) ? msg.method : null;
379
+ const work = Promise.resolve().then(() => {
380
+ if (!method) throw new Error("unknown nova method: " + msg.method);
381
+
382
+ return method(this.nova, msg.args);
383
+ });
384
+
385
+ this.pending.add(work);
386
+ work.then(
387
+ value => this.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
388
+ err => {
389
+ if (!this.accepting) this.hostError ??= err instanceof Error ? err.message : String(err);
390
+ this.postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
391
+ },
392
+ ).finally(() => this.pending.delete(work));
393
+ }
394
+
395
+ onDone(msg) {
396
+ try {
397
+ const packed = packageFinalReturn(msg.value, this.logs, this.config);
398
+ void this.complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
399
+ returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
400
+ logs: packed.logs, logTruncated: this.logTruncated || packed.logTruncated });
401
+ } catch (err) {
402
+ void this.complete(this.fail(err.message));
403
+ }
404
+ }
335
405
 
336
- if (file !== undefined) code = await readProgramFile(file, cwd, config.maxCodeChars ?? 48000, inputController.signal);
406
+ guestError(msg) {
407
+ const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
408
+ void this.complete(this.fail(msg.message + where));
409
+ }
410
+
411
+ dispatchGuest(msg) {
412
+ if (msg.op === "log") this.onLog(msg);
413
+ else if (msg.op === "logTruncated") this.logTruncated = true;
414
+ else if (msg.op === "rpc") this.onRpc(msg);
415
+ else if (msg.op === "done") this.onDone(msg);
416
+ else if (msg.op === "error") this.guestError(msg);
417
+ }
337
418
 
338
- if (finished) return;
419
+ onMessage = (msg) => {
420
+ if (this.finished || !this.accepting || !isObject(msg) || msg.runId !== this.runId) return;
421
+ this.dispatchGuest(msg);
422
+ };
339
423
 
340
- if (!code.trim()) return finish(fail("code must be a non-empty string; no commands ran"));
341
- let prepared;
424
+ signalAbort = () => { if (!this.notifyingHost) this.abort(); };
342
425
 
343
- try { prepared = prepareProgram(code); }
344
- catch (error) { return finish(fail("JavaScript syntax error: " + error.message + "; no commands ran. Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args}).")); }
426
+ async loadCode() {
427
+ if (this.file !== undefined) this.code = await readProgramFile(this.file, this.cwd, this.config.maxCodeChars ?? 48000, this.inputController.signal);
428
+ if (this.finished) return false;
429
+ if (!this.code.trim()) { this.finish(this.fail("code must be a non-empty string; no commands ran")); return false; }
345
430
 
346
- if (wall() >= timeoutMs) return abort();
347
- handle = acquireWorker(config);
348
- await handle.ready;
431
+ try { this.prepared = prepareProgram(this.code); }
432
+ catch (error) { this.finish(this.fail("JavaScript syntax error: " + error.message + "; no commands ran. Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args}).")); return false; }
349
433
 
350
- if (finished || signal?.aborted) return abort();
351
- let available = isFunction(nova.names) ? await nova.names() : [];
434
+ return true;
435
+ }
352
436
 
353
- if (!Array.isArray(available)) available = [];
437
+ async attachWorker() {
438
+ if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
439
+ this.handle = acquireWorker(this.config);
440
+ await this.handle.ready;
441
+ if (this.finished || this.signal?.aborted) { this.abort(); return false; }
442
+ let available = isFunction(this.nova.names) ? await this.nova.names() : [];
443
+
444
+ if (!Array.isArray(available)) available = [];
445
+ if (this.finished || this.signal?.aborted) { this.abort(); return false; }
446
+ this.handle.worker.on("message", this.onMessage);
447
+ this.handle.worker.on("error", this.onError);
448
+ this.handle.worker.on("exit", this.onExit);
449
+ if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
450
+ this.available = available;
451
+
452
+ return true;
453
+ }
354
454
 
355
- if (finished || signal?.aborted) return abort();
356
- handle.worker.on("message", onMessage);
357
- handle.worker.on("error", onError);
358
- handle.worker.on("exit", onExit);
455
+ postRun() {
456
+ this.handle.worker.postMessage({ op: "run", runId: this.runId, prepared: this.prepared, data: this.data, available: this.available,
457
+ batchRead: this.nova.batchRead !== false,
458
+ nativeArgv: this.nova.nativeArgv === true,
459
+ limits: { maxLogLines: this.config.maxLogLines ?? 100, maxLogLineChars: this.config.maxLogLineChars ?? 4096 } });
460
+ }
359
461
 
360
- if (wall() >= timeoutMs) return abort();
361
- handle.worker.postMessage({ op: "run", runId, prepared, data, available,
362
- batchRead: nova.batchRead !== false,
363
- nativeArgv: nova.nativeArgv === true,
364
- limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
365
- } catch (err) {
366
- if (finished) return;
367
- cancelHost();
368
- finish(fail("program failed to start: " + err.message + "; no commands ran"));
369
- }
370
- })();
371
- });
462
+ async boot() {
463
+ try {
464
+ if (this.signal?.aborted) return this.abort();
465
+ if (!await this.loadCode()) return;
466
+ if (!await this.attachWorker()) return;
467
+ this.postRun();
468
+ } catch (err) {
469
+ if (this.finished) return;
470
+ this.cancelHost();
471
+ this.finish(this.fail("program failed to start: " + err.message + "; no commands ran"));
472
+ }
473
+ }
474
+
475
+ start() {
476
+ return new Promise((resolve) => {
477
+ this.resolve = resolve;
478
+ this.timer = setTimeout(() => this.abort(), Math.min(this.timeoutMs, 2147483647));
479
+ this.memTimer = setInterval(() => {
480
+ const now = rssBytes();
481
+
482
+ if (now <= this.rssLimit) return;
483
+ this.cancelHost();
484
+ let tracked = null;
485
+
486
+ try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
487
+ this.finish(this.fail(formatMemoryAttribution({
488
+ limitMb: this.config.maxHeapMb ?? 512, rssBytes: now, startBytes: this.rssStart ?? now,
489
+ ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
490
+ })));
491
+ }, MEMORY_POLL_MS);
492
+ this.signal?.addEventListener("abort", this.signalAbort, { once: true });
493
+ void this.boot();
494
+ });
495
+ }
496
+ }
497
+
498
+ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, nova = {}, config = {}, signal, onTimeout }) {
499
+ const admitted = admitGuest({ code, file, data, config });
500
+ const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs: [], logTruncated: false, wallMs: 0 });
501
+
502
+ if (admitted.error) return fail(admitted.error);
503
+ if (signal?.aborted) return fail(ABORT_MESSAGE);
504
+ const rssStart = rssBytes();
505
+
506
+ return new GuestRun({
507
+ code, file, cwd, data: admitted.data, nova, config, signal, onTimeout,
508
+ runId: ++runSeq,
509
+ timeoutMs: admitted.timeoutMs,
510
+ rssLimit: rssStart + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576, rssStart,
511
+ }).start();
372
512
  }