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
@@ -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(0, 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,203 +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 };
168
219
 
169
- if ((code === undefined) === (file === undefined)) return fail("supply exactly one of code or file; no commands ran");
220
+ try {
221
+ const encoded = JSON.stringify(data);
170
222
 
171
- if (file === undefined && (!isString(code) || !code.trim())) return fail("code must be a non-empty string");
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" };
172
225
 
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");
226
+ return { data: JSON.parse(encoded) };
227
+ } catch { return { error: "data must be JSON-serializable" }; }
228
+ }
174
229
 
175
- if (data !== undefined) {
176
- try {
177
- const encoded = JSON.stringify(data);
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
+ }
178
235
 
179
- if (encoded === undefined) return fail("data must be JSON-serializable");
236
+ function admitTimeout(config) {
237
+ const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
180
238
 
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
- }
239
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
185
240
 
186
- if (signal?.aborted) return fail(ABORT_MESSAGE);
187
- const runId = ++runSeq;
188
- const timeoutMs = config.timeoutMs ?? 60000;
189
- const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
190
-
191
- return new Promise((resolve) => {
192
- let handle;
193
- let finished = false;
194
- let accepting = true;
195
- let completing = false;
196
- let hostError;
197
- let notifyingHost = false;
198
- const pending = new Set();
199
- const inputController = new AbortController();
241
+ return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
242
+ }
200
243
 
201
- const cleanup = () => {
202
- inputController.abort();
203
- clearTimeout(timer);
204
- clearInterval(memTimer);
205
- signal?.removeEventListener("abort", signalAbort);
206
- handle?.worker.off("message", onMessage);
207
- handle?.worker.off("error", onError);
208
- handle?.worker.off("exit", onExit);
209
- };
244
+ function admitGuest({ code, file, data, config }) {
245
+ const cap = config.maxCodeChars ?? 48000;
246
+ const codeError = admitCode({ code, file, cap });
210
247
 
211
- const finish = (outcome) => {
212
- if (finished) return;
213
- finished = true;
214
- accepting = false;
215
- cleanup();
216
- void killWorker(handle);
217
- resolve({ ...outcome, wallMs: wall() });
218
- };
248
+ if (codeError) return codeError;
249
+ const admitted = admitData(data, cap);
219
250
 
220
- const cancelHost = () => {
221
- notifyingHost = true;
251
+ if (admitted.error) return admitted;
252
+ const timeout = admitTimeout(config);
222
253
 
223
- try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
224
- };
254
+ if (timeout.error) return timeout;
225
255
 
226
- const abort = () => {
227
- if (finished) return;
228
- cancelHost();
256
+ return { data: admitted.data, timeoutMs: timeout.timeoutMs };
257
+ }
229
258
 
230
- try { onTimeout?.(); } catch {}
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
+ }
231
288
 
232
- finish(fail(ABORT_MESSAGE));
233
- };
289
+ wall() {
290
+ return Math.round(performance.now() - this.started);
291
+ }
292
+
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
+ }
296
+
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
+ }
234
315
 
235
- const signalAbort = () => { if (!notifyingHost) abort(); };
316
+ cancelHost() {
317
+ this.notifyingHost = true;
236
318
 
237
- const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
319
+ try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
320
+ }
238
321
 
239
- const memTimer = setInterval(() => {
240
- if (rssBytes() <= rssLimit) return;
241
- cancelHost();
242
- finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
243
- }, MEMORY_POLL_MS);
322
+ abort() {
323
+ if (this.finished || this.aborting) return;
324
+ this.aborting = true;
325
+ this.cancelHost();
244
326
 
245
- signal?.addEventListener("abort", signalAbort, { once: true });
327
+ try { this.onTimeout?.(); } catch {}
246
328
 
247
- const postResult = (message) => {
248
- if (!accepting || finished) return;
329
+ this.aborting = false;
330
+ this.finish(this.fail(ABORT_MESSAGE));
331
+ }
249
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) {
250
339
  try {
251
- handle.worker.postMessage({ ...message, runId });
252
- } catch (err) {
253
- try {
254
- handle.worker.postMessage({ op: "rpc:result", id: message.id, runId, ok: false, error: "result not transferable: " + err.message });
255
- } catch (error) {
256
- onError(error);
257
- }
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);
258
343
  }
259
- };
344
+ }
345
+ }
260
346
 
261
- const complete = async (outcome) => {
262
- if (finished || completing) return;
263
- completing = true;
264
- accepting = false;
265
- // Stop timers and detached guest continuations before settling host work.
266
- handle?.worker.off("error", onError);
267
- handle?.worker.off("exit", onExit);
268
- void killWorker(handle);
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
+ }
269
352
 
270
- if (!outcome.ok) cancelHost();
271
- await Promise.allSettled(pending);
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
+ }
272
364
 
273
- if (finished) return;
274
- finish(outcome.ok && hostError ? fail(hostError) : outcome);
275
- };
365
+ onError = (err) => { void this.complete(this.fail("guest crashed: " + err.message)); };
276
366
 
277
- const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
367
+ onExit = (exitCode) => { void this.complete(this.fail("guest exited (code " + exitCode + ")")); };
278
368
 
279
- const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
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
+ }
280
374
 
281
- const onMessage = (msg) => {
282
- if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
283
-
284
- if (msg.op === "log") {
285
- if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
286
- else logTruncated = true;
287
- logTruncated ||= msg.truncated === true;
288
- } else if (msg.op === "logTruncated") {
289
- logTruncated = true;
290
- } else if (msg.op === "rpc") {
291
- const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
292
-
293
- const work = Promise.resolve().then(() => {
294
- if (!method) throw new Error("unknown nova method: " + msg.method);
295
-
296
- return method(nova, msg.args);
297
- });
298
-
299
- pending.add(work);
300
- work.then(
301
- value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
302
- err => {
303
- // An awaited, handled host error must not poison the whole program.
304
- if (!accepting) hostError ??= err instanceof Error ? err.message : String(err);
305
- postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
306
- },
307
- ).finally(() => pending.delete(work));
308
- } else if (msg.op === "done") {
309
- try {
310
- const packed = packageFinalReturn(msg.value, logs, config);
311
- void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
312
- returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
313
- logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
314
- } catch (err) {
315
- void complete(fail(err.message));
316
- }
317
- } else if (msg.op === "error") {
318
- const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
319
- void complete(fail(msg.message + where));
320
- }
321
- };
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
+ }
322
394
 
323
- void (async () => {
324
- try {
325
- if (signal?.aborted) return abort();
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
+ }
326
405
 
327
- 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
+ }
328
410
 
329
- if (finished) return;
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
+ }
330
418
 
331
- if (!code.trim()) return finish(fail("code must be a non-empty string; no commands ran"));
332
- let prepared;
419
+ onMessage = (msg) => {
420
+ if (this.finished || !this.accepting || !isObject(msg) || msg.runId !== this.runId) return;
421
+ this.dispatchGuest(msg);
422
+ };
333
423
 
334
- try { prepared = prepareProgram(code); }
335
- 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}).")); }
424
+ signalAbort = () => { if (!this.notifyingHost) this.abort(); };
336
425
 
337
- if (wall() >= timeoutMs) return abort();
338
- handle = acquireWorker(config);
339
- await handle.ready;
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; }
340
430
 
341
- if (finished || signal?.aborted) return abort();
342
- const available = isFunction(nova.names) ? await nova.names() : [];
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; }
343
433
 
344
- if (finished || signal?.aborted) return abort();
345
- handle.worker.on("message", onMessage);
346
- handle.worker.on("error", onError);
347
- handle.worker.on("exit", onExit);
434
+ return true;
435
+ }
348
436
 
349
- if (wall() >= timeoutMs) return abort();
350
- handle.worker.postMessage({ op: "run", runId, prepared, data, available,
351
- batchRead: nova.batchRead !== false,
352
- nativeArgv: nova.nativeArgv === true,
353
- limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
354
- } catch (err) {
355
- if (finished) return;
356
- cancelHost();
357
- finish(fail("program failed to start: " + err.message + "; no commands ran"));
358
- }
359
- })();
360
- });
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
+ }
454
+
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
+ }
461
+
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();
361
512
  }