pi-supernova 0.8.2 → 0.9.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 (66) hide show
  1. package/README.md +188 -51
  2. package/docs/CHANGELOG.md +86 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +14 -30
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/query.js +71 -0
  21. package/src/context/repo-index.js +8 -162
  22. package/src/context/search-files.js +19 -0
  23. package/src/context/search.js +2 -24
  24. package/src/context/snap-search.js +202 -0
  25. package/src/context/snap.js +5 -266
  26. package/src/context/source-entry.js +112 -0
  27. package/src/contract/bash.js +6 -1
  28. package/src/contract/program.js +36 -0
  29. package/src/contract/read.js +8 -53
  30. package/src/fs/check.js +1 -1
  31. package/src/fs/commit.js +161 -0
  32. package/src/fs/diff.js +11 -15
  33. package/src/fs/directory.js +79 -0
  34. package/src/fs/file-io.js +100 -0
  35. package/src/fs/glob.js +54 -0
  36. package/src/fs/json-size.js +54 -0
  37. package/src/fs/lines.js +117 -0
  38. package/src/fs/read-window.js +74 -0
  39. package/src/fs/session-resource.js +50 -0
  40. package/src/fs/text-ops.js +7 -227
  41. package/src/fs/vfs.js +5 -239
  42. package/src/fs/workspace.js +2 -1
  43. package/src/output/bottleneck.js +13 -67
  44. package/src/output/final.js +114 -0
  45. package/src/output/format.js +94 -5
  46. package/src/output/outcome.js +91 -0
  47. package/src/runtime/batch-input.js +68 -0
  48. package/src/runtime/guest-api.js +281 -0
  49. package/src/runtime/guest-worker.js +62 -333
  50. package/src/runtime/parallel.js +41 -39
  51. package/src/runtime/program-batch.js +21 -75
  52. package/src/runtime/program-file.js +3 -11
  53. package/src/runtime/program.js +141 -0
  54. package/src/runtime/reference.js +6 -5
  55. package/src/runtime/runtime.js +77 -253
  56. package/src/runtime/worker-pool.js +91 -0
  57. package/src/shared/decode.js +22 -8
  58. package/src/shared/image-worker.js +30 -0
  59. package/src/shared/image.js +78 -0
  60. package/src/shared/png.js +57 -0
  61. package/src/shared/result.js +77 -0
  62. package/src/shared/syntax-context.js +61 -3
  63. package/src/ui/host-render.js +104 -0
  64. package/src/ui/progress.js +51 -0
  65. package/src/ui/render.js +21 -421
  66. package/src/ui/trace.js +277 -0
@@ -1,211 +1,41 @@
1
- import { Worker } from "node:worker_threads";
1
+ import {acquireWorker,killWorker} from './worker-pool.js';
2
+ export {warmGuestWorker,stopWarmGuestWorker} from './worker-pool.js';
3
+ import {admitGuest,prepareProgram,normalizeGuestLocation} from './program.js';
4
+
2
5
  import { readProgramFile } from "./program-file.js";
3
- import { parse } from "acorn";
6
+
4
7
  import { performance } from "node:perf_hooks";
5
8
  import { packageFinalReturn } from "../output/bottleneck.js";
6
9
  import { truncateChars } from "../output/format.js";
7
- import { isFunction, isObject, isString } from "../shared/decode.js";
8
- import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
9
- import { errorContext } from "../shared/syntax-context.js";
10
+ import { errorMessage, isFunction, isObject, isString } from "../shared/decode.js";
10
11
 
11
- const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
12
+ import { errorContext } from "../shared/syntax-context.js";
13
+ import {validateReturnedImages} from "../shared/image.js";
12
14
 
13
15
  const ABORT_MESSAGE = "supernova aborted";
14
16
  const TIMEOUT_MESSAGE = "supernova timed out: increase the outer timeoutMs (and any shorter bash timeoutMs), or split the program; sleeps count toward the deadline";
15
17
 
16
- const MEMORY_POLL_MS = 50;
17
-
18
18
  const MEMORY_SLACK = 1.5;
19
19
 
20
- const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
21
-
22
20
  const toMb = bytes => Math.max(0, bytes / 1048576);
23
21
 
24
- /** Pure attribution for a memory-limit trip: which operation, and how much of the RSS growth supernova can account for. */
25
- export function formatMemoryAttribution({ limitMb, rssBytes: now, startBytes, ms, op, calls, tracked }) {
26
- const deltaMb = toMb(now - startBytes);
22
+ /** Only worker-local usage can trip the guard; host structures are diagnostic. */
23
+ export function formatMemoryAttribution({ limitMb, heapBytes, externalBytes, ms, op, calls, tracked }) {
27
24
  const parts = tracked
28
25
  ? [["vfs cache", tracked.vfsCacheBytes], ["index entries", tracked.indexBytes], ["overlays", tracked.overlayBytes]]
29
26
  : [];
30
- const trackedMb = parts.reduce((sum, [, bytes]) => sum + toMb(Number(bytes) || 0), 0);
31
- const untrackedMb = Math.max(0, deltaMb - trackedMb);
32
27
  const where = op ? ` during ${op} (${calls} host calls)` : ` (${calls} host calls)`;
33
28
  const seen = parts.length
34
- ? `; supernova-tracked host bytes: ${parts.map(([name, bytes]) => `${name} ${toMb(Number(bytes) || 0).toFixed(1)}MB`).join(", ")} in ${tracked.overlayFiles ?? 0} overlay files`
29
+ ? `; supernova-tracked host bytes (not charged to guest): ${parts.map(([name, bytes]) => `${name} ${toMb(Number(bytes) || 0).toFixed(1)}MB`).join(", ")} in ${tracked.overlayFiles ?? 0} overlay files`
35
30
  : "";
36
31
 
37
- return `guest exceeded memory limit (maxHeapMb=${limitMb}): process RSS +${deltaMb.toFixed(1)}MB in ${Math.round(ms)}ms${where}${seen}; `
38
- + `~${untrackedMb.toFixed(1)}MB untracked (worker heap, transient buffers, or host/concurrent growth outside supernova)`;
32
+ return `guest exceeded memory limit (maxHeapMb=${limitMb}): worker heap ${toMb(heapBytes).toFixed(1)}MB + external ${toMb(externalBytes).toFixed(1)}MB in ${Math.round(ms)}ms${where}${seen}`;
39
33
  }
40
34
 
41
- let idleWorker = null;
42
-
43
35
  let runSeq = 0;
44
36
 
45
- const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
46
-
47
- const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
48
-
49
- function hasReturn(node) {
50
- if (!isObject(node)) return false;
51
-
52
- if (node.type === "ReturnStatement") return true;
53
-
54
- if (FUNCTION_TYPES.has(node.type)) return false;
55
-
56
- return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
57
- }
58
-
59
- function parseExpressionFunction(code) {
60
- try {
61
- const program = parse(code, PARSE_OPTIONS);
62
- const statements = program.body.filter(node => node.type !== "EmptyStatement");
63
- const statement = statements.length === 1 ? statements[0] : undefined;
64
- const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
65
-
66
- if (candidate && FUNCTION_TYPES.has(candidate.type)) {
67
- return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
68
- }
69
-
70
- return { program };
71
- } catch (bodyError) {
72
- const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
73
-
74
- try {
75
- const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
76
- const expression = wrapped.body[0]?.expression;
77
-
78
- if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
79
-
80
- return { expression, expressionSource };
81
- } catch { throw bodyError; }
82
- }
83
- }
84
-
85
- function deniedSpecifier(node) {
86
- if (node?.type === "Literal" && isString(node.value)) return node.value;
87
- if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
88
- }
89
-
90
- function assertGuestImports(ast) {
91
- function walk(node) {
92
- if (Array.isArray(node)) { node.forEach(walk); return; }
93
- if (!isObject(node)) return;
94
- if (node.type === "ImportDeclaration" || node.type === "ImportExpression") {
95
- const spec = deniedSpecifier(node.source);
96
- throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
97
- }
98
- if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") {
99
- const spec = deniedSpecifier(node.arguments?.[0]);
100
- throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
101
- }
102
- for (const key of Object.keys(node)) {
103
- if (key === "start" || key === "end" || key === "loc" || key === "range") continue;
104
- walk(node[key]);
105
- }
106
- }
107
-
108
- walk(ast);
109
- }
110
-
111
- function prepareProgram(code) {
112
- const parsed = parseExpressionFunction(code);
113
- assertGuestImports(parsed.program ?? parsed.expression);
114
- const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
115
- const returns = parsed.expression
116
- ? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
117
- : hasReturn(parsed.program);
118
-
119
- return { body, hasReturn: returns };
120
- }
121
-
122
- function spawnWorker(config) {
123
- const maxHeapMb = config.maxHeapMb ?? 512;
124
- // An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
125
- // Keep Node's automatic flag inheritance: explicitly copying execArgv can
126
- // reintroduce process-only V8 flags that Worker rejects under node --test.
127
- const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
128
- const handle = { worker, maxHeapMb, dead: false, ready: null };
129
- // This listener also owns errors between readiness and a run's listeners.
130
- worker.on("error", () => { handle.dead = true; });
131
- worker.on("exit", () => {
132
- handle.dead = true;
133
-
134
- if (idleWorker === handle) idleWorker = null;
135
- });
136
- handle.ready = new Promise((resolve, reject) => {
137
- const cleanup = () => {
138
- worker.off("message", onMessage);
139
- worker.off("error", onFail);
140
- worker.off("exit", onFail);
141
- };
142
-
143
- const onMessage = (msg) => {
144
- if (msg?.op !== "ready") return;
145
- cleanup();
146
- resolve();
147
- };
148
-
149
- const onFail = (err) => {
150
- cleanup();
151
- reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
152
- };
153
-
154
- worker.on("message", onMessage);
155
- worker.on("error", onFail);
156
- worker.on("exit", onFail);
157
- });
158
- handle.ready.catch(() => {});
159
-
160
- return handle;
161
- }
162
-
163
- function killWorker(handle) {
164
- if (!handle) return;
165
-
166
- if (idleWorker === handle) idleWorker = null;
167
- handle.dead = true;
168
-
169
- return handle.worker.terminate();
170
- }
171
-
172
- function acquireWorker(config) {
173
- const candidate = idleWorker;
174
- idleWorker = null;
175
- const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
176
-
177
- if (candidate && !reusable) void killWorker(candidate);
178
- const handle = reusable ? candidate : spawnWorker(config);
179
- handle.worker.ref?.();
180
-
181
- // Pipeline the successor while this run executes. A consumed worker's
182
- // replacement starts at once; a cold start's replacement waits for ready,
183
- // so the two constructions never overlap. The finish-time warm usually
184
- // becomes a no-op, so steady-state spawn count is unchanged.
185
- if (reusable) warmGuestWorker(config).catch(() => {});
186
- else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
187
-
188
- return handle;
189
- }
190
-
191
- /** Only pristine workers may be prewarmed. A used worker is never pooled. */
192
- export function warmGuestWorker(config = {}) {
193
- if (idleWorker && !idleWorker.dead) return idleWorker.ready;
194
- const handle = spawnWorker(config);
195
- idleWorker = handle;
196
- handle.ready.then(() => {
197
- if (idleWorker === handle) handle.worker.unref?.();
198
- }, () => {});
199
-
200
- return handle.ready;
201
- }
202
-
203
- export function stopWarmGuestWorker() {
204
- return killWorker(idleWorker);
205
- }
206
-
207
37
  const RPC_METHODS = {
208
- call: (nova, args) => nova.call(args[0], args[1]),
38
+ call: (nova, args, onItem) => nova.call(args[0], args[1], onItem),
209
39
  callMany: async (nova, args) => {
210
40
  const wave = await nova.callMany(args[0]);
211
41
 
@@ -216,50 +46,8 @@ const RPC_METHODS = {
216
46
  speculateRollback: (nova) => nova.speculateRollback(),
217
47
  };
218
48
 
219
- function admitData(data, cap) {
220
- if (data === undefined) return { data };
221
-
222
- try {
223
- const encoded = JSON.stringify(data);
224
-
225
- if (encoded === undefined) return { error: "data must be JSON-serializable" };
226
- if (encoded.length > cap) return { error: "data exceeds " + cap + " characters (serialized JSON: " + encoded.length + " UTF-16 characters); no commands ran. Split literal inputs across invocations; large text can use write({path,content,append:true}) chunks without omitting content" };
227
-
228
- return { data: JSON.parse(encoded) };
229
- } catch { return { error: "data must be JSON-serializable" }; }
230
- }
231
-
232
- function admitCode({ code, file, cap }) {
233
- if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
234
- if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
235
- if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
236
- }
237
-
238
- function admitTimeout(config) {
239
- const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
240
-
241
- if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
242
-
243
- return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
244
- }
245
-
246
- function admitGuest({ code, file, data, config }) {
247
- const cap = config.maxCodeChars ?? 48000;
248
- const codeError = admitCode({ code, file, cap });
249
-
250
- if (codeError) return codeError;
251
- const admitted = admitData(data, cap);
252
-
253
- if (admitted.error) return admitted;
254
- const timeout = admitTimeout(config);
255
-
256
- if (timeout.error) return timeout;
257
-
258
- return { data: admitted.data, timeoutMs: timeout.timeoutMs };
259
- }
260
-
261
49
  class GuestRun {
262
- constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs, rssLimit, rssStart }) {
50
+ constructor({ code, file, cwd, data, nova, config, signal, onTimeout, runId, timeoutMs }) {
263
51
  this.code = code;
264
52
  this.file = file;
265
53
  this.cwd = cwd;
@@ -270,8 +58,6 @@ class GuestRun {
270
58
  this.onTimeout = onTimeout;
271
59
  this.runId = runId;
272
60
  this.timeoutMs = timeoutMs;
273
- this.rssLimit = rssLimit;
274
- this.rssStart = rssStart;
275
61
  this.started = performance.now();
276
62
  this.logs = [];
277
63
  this.logTruncated = false;
@@ -285,6 +71,11 @@ class GuestRun {
285
71
  this.abortOutcome = undefined;
286
72
  this.pending = new Set();
287
73
  this.inputController = new AbortController();
74
+ this.deliveries = new Map();
75
+ this.inputController.signal.addEventListener("abort", () => {
76
+ for (const delivery of this.deliveries.values()) delivery.reject(new Error("aborted"));
77
+ this.deliveries.clear();
78
+ }, {once:true});
288
79
  this.rpcCount = 0;
289
80
  this.lastRpcMethod = null;
290
81
  }
@@ -300,7 +91,6 @@ class GuestRun {
300
91
  cleanup() {
301
92
  this.inputController.abort();
302
93
  clearTimeout(this.timer);
303
- clearInterval(this.memTimer);
304
94
  this.signal?.removeEventListener("abort", this.signalAbort);
305
95
  this.handle?.worker.off("message", this.onMessage);
306
96
  this.handle?.worker.off("error", this.onError);
@@ -337,19 +127,42 @@ class GuestRun {
337
127
  }
338
128
 
339
129
  postResult(message) {
340
- if (!this.accepting || this.finished) return;
130
+ if (!this.accepting || this.finished) return false;
341
131
 
342
132
  try {
343
133
  this.handle.worker.postMessage({ ...message, runId: this.runId });
134
+ return true;
344
135
  } catch (err) {
345
136
  try {
346
137
  this.handle.worker.postMessage({ op: "rpc:result", id: message.id, runId: this.runId, ok: false, error: "result not transferable: " + err.message });
347
138
  } catch (error) {
348
139
  this.onError(error);
349
140
  }
141
+ return false;
350
142
  }
351
143
  }
352
144
 
145
+ deliverReadItem(id, index, value) {
146
+ if (!this.accepting) return Promise.reject(new Error("aborted"));
147
+ const key = id + ":" + index;
148
+ return new Promise((resolve,reject) => {
149
+ this.deliveries.set(key,{resolve,reject});
150
+ if (!this.postResult({op:"rpc:item",id,index,value})) {
151
+ this.deliveries.delete(key);
152
+ reject(new Error("read result not transferable"));
153
+ }
154
+ });
155
+ }
156
+
157
+ onReadAck(msg) {
158
+ const key = msg.id + ":" + msg.index;
159
+ const delivery = this.deliveries.get(key);
160
+ if (!delivery) return;
161
+ this.deliveries.delete(key);
162
+ if (msg.error) delivery.reject(new Error(msg.error));
163
+ else delivery.resolve();
164
+ }
165
+
353
166
  async drainPending(outcome) {
354
167
  if (this.pending.size || !outcome.ok) this.cancelHost();
355
168
  if (!this.pending.size) return;
@@ -365,6 +178,7 @@ class GuestRun {
365
178
  if (this.finished || this.completing) return;
366
179
  this.completing = true;
367
180
  this.accepting = false;
181
+ this.inputController.abort();
368
182
  this.handle?.worker.off("error", this.onError);
369
183
  this.handle?.worker.off("exit", this.onExit);
370
184
  void killWorker(this.handle);
@@ -394,39 +208,63 @@ class GuestRun {
394
208
  const work = Promise.resolve().then(() => {
395
209
  if (!method) throw new Error("unknown nova method: " + msg.method);
396
210
 
397
- return method(this.nova, msg.args);
211
+ const onItem = msg.streamRead && msg.method === "call" && msg.args?.[0] === "read"
212
+ ? (index,value) => this.deliverReadItem(msg.id,index,value) : undefined;
213
+ return method(this.nova, msg.args, onItem);
398
214
  });
399
215
 
400
216
  this.pending.add(work);
401
217
  work.then(
402
218
  value => this.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
403
219
  err => {
404
- if (!this.accepting) this.hostError ??= err instanceof Error ? err.message : String(err);
405
- this.postResult({ op: "rpc:result", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
220
+ if (!this.accepting) this.hostError ??= errorMessage(err);
221
+ this.postResult({ op: "rpc:result", id: msg.id, ok: false, error: errorMessage(err) });
406
222
  },
407
223
  ).finally(() => this.pending.delete(work));
408
224
  }
409
225
 
410
- onDone(msg) {
226
+ async onDone(msg) {
227
+ // A completed guest cannot submit more work while attachments are decoded.
228
+ // The outer timer stays armed; no commit occurs before validation completes.
229
+ this.accepting = false;
411
230
  try {
412
- const packed = packageFinalReturn(msg.value, this.logs, this.config);
231
+ const packed = msg.output ?? packageFinalReturn(msg.value, this.logs, this.config);
232
+ if (packed.images?.length) await validateReturnedImages(packed.images,this.inputController.signal);
413
233
  void this.complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
414
234
  returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
415
- logs: packed.logs, logTruncated: this.logTruncated || packed.logTruncated });
235
+ logs: this.logs, logTruncated: this.logTruncated || packed.logTruncated });
416
236
  } catch (err) {
417
237
  void this.complete(this.fail(err.message));
418
238
  }
419
239
  }
420
240
 
421
241
  guestError(msg) {
422
- const where = msg.location ? " (line " + msg.location.line + ":" + msg.location.col + ")" : "";
242
+ const location = normalizeGuestLocation(this.prepared.body, msg.location);
243
+ const where = location ? " (line " + location.line + ":" + location.col + ")" : "";
423
244
  void this.complete(this.fail(msg.message + where));
424
245
  }
425
246
 
247
+ onMemory(msg) {
248
+ const { heapUsed, external } = msg;
249
+ if (![heapUsed, external].every(bytes => Number.isFinite(bytes) && bytes >= 0)) return;
250
+ if (heapUsed + external <= (this.config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576) return;
251
+ let tracked = null;
252
+
253
+ try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
254
+ // Use the same cancellation/drain path as other failures, not an early
255
+ // finish that can return while a host command is still mutating files.
256
+ void this.complete(this.fail(formatMemoryAttribution({
257
+ limitMb: this.config.maxHeapMb ?? 512, heapBytes: heapUsed, externalBytes: external,
258
+ ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
259
+ })));
260
+ }
261
+
426
262
  dispatchGuest(msg) {
427
263
  if (msg.op === "log") this.onLog(msg);
428
264
  else if (msg.op === "logTruncated") this.logTruncated = true;
265
+ else if (msg.op === "memory") this.onMemory(msg);
429
266
  else if (msg.op === "rpc") this.onRpc(msg);
267
+ else if (msg.op === "rpc:ack") this.onReadAck(msg);
430
268
  else if (msg.op === "done") this.onDone(msg);
431
269
  else if (msg.op === "error") this.guestError(msg);
432
270
  }
@@ -478,7 +316,8 @@ class GuestRun {
478
316
  this.handle.worker.postMessage({ op: "run", runId: this.runId, prepared: this.prepared, data: this.data, available: this.available,
479
317
  batchRead: this.nova.batchRead !== false,
480
318
  nativeArgv: this.nova.nativeArgv === true,
481
- limits: { maxLogLines: this.config.maxLogLines ?? 100, maxLogLineChars: this.config.maxLogLineChars ?? 4096 } });
319
+ formatResult:true,
320
+ limits: { maxReturnChars:this.config.maxReturnChars ?? 32000, maxLogLines: this.config.maxLogLines ?? 100, maxLogLineChars: this.config.maxLogLineChars ?? 4096 } });
482
321
  }
483
322
 
484
323
  async boot() {
@@ -498,19 +337,6 @@ class GuestRun {
498
337
  return new Promise((resolve) => {
499
338
  this.resolve = resolve;
500
339
  this.timer = setTimeout(() => this.abort(true), Math.min(this.timeoutMs, 2147483647));
501
- this.memTimer = setInterval(() => {
502
- const now = rssBytes();
503
-
504
- if (now <= this.rssLimit) return;
505
- this.cancelHost();
506
- let tracked = null;
507
-
508
- try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
509
- this.finish(this.fail(formatMemoryAttribution({
510
- limitMb: this.config.maxHeapMb ?? 512, rssBytes: now, startBytes: this.rssStart ?? now,
511
- ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
512
- })));
513
- }, MEMORY_POLL_MS);
514
340
  this.signal?.addEventListener("abort", this.signalAbort, { once: true });
515
341
  void this.boot();
516
342
  });
@@ -523,12 +349,10 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
523
349
 
524
350
  if (admitted.error) return fail(admitted.error);
525
351
  if (signal?.aborted) return fail(ABORT_MESSAGE);
526
- const rssStart = rssBytes();
527
352
 
528
353
  return new GuestRun({
529
354
  code, file, cwd, data: admitted.data, nova, config, signal, onTimeout,
530
355
  runId: ++runSeq,
531
356
  timeoutMs: admitted.timeoutMs,
532
- rssLimit: rssStart + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576, rssStart,
533
357
  }).start();
534
358
  }
@@ -0,0 +1,91 @@
1
+ import {Worker} from 'node:worker_threads';
2
+
3
+ const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
4
+
5
+ let idleWorker = null;
6
+
7
+ function spawnWorker(config) {
8
+ const maxHeapMb = config.maxHeapMb ?? 512;
9
+ // An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
10
+ // Keep Node's automatic flag inheritance: explicitly copying execArgv can
11
+ // reintroduce process-only V8 flags that Worker rejects under node --test.
12
+ const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
13
+ const handle = { worker, maxHeapMb, dead: false, ready: null };
14
+ // This listener also owns errors between readiness and a run's listeners.
15
+ worker.on("error", () => { handle.dead = true; });
16
+ worker.on("exit", () => {
17
+ handle.dead = true;
18
+
19
+ if (idleWorker === handle) idleWorker = null;
20
+ });
21
+ handle.ready = new Promise((resolve, reject) => {
22
+ const cleanup = () => {
23
+ worker.off("message", onMessage);
24
+ worker.off("error", onFail);
25
+ worker.off("exit", onFail);
26
+ };
27
+
28
+ const onMessage = (msg) => {
29
+ if (msg?.op !== "ready") return;
30
+ cleanup();
31
+ resolve();
32
+ };
33
+
34
+ const onFail = (err) => {
35
+ cleanup();
36
+ reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
37
+ };
38
+
39
+ worker.on("message", onMessage);
40
+ worker.on("error", onFail);
41
+ worker.on("exit", onFail);
42
+ });
43
+ handle.ready.catch(() => {});
44
+
45
+ return handle;
46
+ }
47
+
48
+ function killWorker(handle) {
49
+ if (!handle) return;
50
+
51
+ if (idleWorker === handle) idleWorker = null;
52
+ handle.dead = true;
53
+
54
+ return handle.worker.terminate();
55
+ }
56
+
57
+ function acquireWorker(config) {
58
+ const candidate = idleWorker;
59
+ idleWorker = null;
60
+ const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
61
+
62
+ if (candidate && !reusable) void killWorker(candidate);
63
+ const handle = reusable ? candidate : spawnWorker(config);
64
+ handle.worker.ref?.();
65
+
66
+ // Pipeline the successor while this run executes. A consumed worker's
67
+ // replacement starts at once; a cold start's replacement waits for ready,
68
+ // so the two constructions never overlap. The finish-time warm usually
69
+ // becomes a no-op, so steady-state spawn count is unchanged.
70
+ if (reusable) warmGuestWorker(config).catch(() => {});
71
+ else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
72
+
73
+ return handle;
74
+ }
75
+
76
+ /** Only pristine workers may be prewarmed. A used worker is never pooled. */
77
+ export function warmGuestWorker(config = {}) {
78
+ if (idleWorker && !idleWorker.dead) return idleWorker.ready;
79
+ const handle = spawnWorker(config);
80
+ idleWorker = handle;
81
+ handle.ready.then(() => {
82
+ if (idleWorker === handle) handle.worker.unref?.();
83
+ }, () => {});
84
+
85
+ return handle.ready;
86
+ }
87
+
88
+ export function stopWarmGuestWorker() {
89
+ return killWorker(idleWorker);
90
+ }
91
+ export { acquireWorker, killWorker };
@@ -26,10 +26,9 @@ export function mapChangedChildren(value, visit, context) {
26
26
  if (!array && !isObject(value)) return value;
27
27
  let out = value;
28
28
 
29
- for (const key of array ? value.keys() : Object.keys(value)) {
30
- if (array && !(key in value)) continue;
29
+ for (const key of childKeys(value)) {
31
30
  const before = value[key];
32
- const after = visit(before, context);
31
+ const after = visit(before, context, key);
33
32
 
34
33
  if (Object.is(before, after)) continue;
35
34
  if (out === value) out = array ? value.slice() : { ...value };
@@ -49,6 +48,15 @@ export function assertModelImageMime(mimeType) {
49
48
  }
50
49
  }
51
50
 
51
+ /** Node's base64 decoder is permissive; model attachments must not be. */
52
+ export function decodeImageData(data) {
53
+ const bytes = Buffer.from(data,"base64");
54
+ if (!data || bytes.toString("base64") !== String(data)) {
55
+ throw new Error("invalid image base64; use canonical padded base64 without a data-URL prefix or extra characters; no image attached");
56
+ }
57
+ return bytes;
58
+ }
59
+
52
60
  const MAX_DEPTH = 64;
53
61
 
54
62
  const MAX_TYPED_ARRAY = 4096;
@@ -85,7 +93,7 @@ function plainFromCollection(value, seen, depth) {
85
93
  if (value instanceof Map) return plainFromMap(value, seen, depth);
86
94
  const out = Object.create(null);
87
95
 
88
- for (const k of Object.keys(value)) out[k] = toPlain(value[k], seen, depth + 1);
96
+ for (const k of childKeys(value)) out[k] = toPlain(value[k], seen, depth + 1);
89
97
 
90
98
  return out;
91
99
  }
@@ -102,9 +110,7 @@ function functionLabel(value) {
102
110
  }
103
111
 
104
112
  function plainByTag(value, tag) {
105
- if (tag === "[object String]") return { hit: true, out: value.valueOf() };
106
-
107
- if (tag === "[object Number]" || tag === "[object Boolean]") return { hit: true, out: value.valueOf() };
113
+ if (["[object String]", "[object Number]", "[object Boolean]"].includes(tag)) return { hit: true, out: value.valueOf() };
108
114
 
109
115
  if (tag === "[object BigInt]") return { hit: true, out: value.toString() + "n" };
110
116
 
@@ -168,4 +174,12 @@ export function toPlain(value, seen = new Set(), depth = 0) {
168
174
  return withSeen(value, seen, () => plainFromCollection(value, seen, depth));
169
175
  }
170
176
 
171
- // ---- RPC to the host thread ----
177
+ export function errorMessage(error) {
178
+ return error instanceof Error ? error.message : String(error);
179
+ }
180
+
181
+ // Arrays visit present indexes (including inherited ones), not extra properties.
182
+ function* childKeys(value) {
183
+ if (!Array.isArray(value)) { yield* Object.keys(value); return; }
184
+ for (const key of value.keys()) if (key in value) yield key;
185
+ }
@@ -0,0 +1,30 @@
1
+ // Isolated decoder: no guest code or user image paths/contents in argv.
2
+ // The parent enforces a wall-clock kill and serializes raster allocations.
3
+ import sharp from "sharp";
4
+
5
+ const MAX_PIXELS = 32_000_000;
6
+ async function inputBytes() {
7
+ const chunks = [];
8
+ let size = 0;
9
+ for await (const chunk of process.stdin) {
10
+ size += chunk.length;
11
+ if (size > 20 * 1024 * 1024) throw new Error("encoded image exceeds 20 MiB");
12
+ chunks.push(chunk);
13
+ }
14
+ return Buffer.concat(chunks,size);
15
+ }
16
+
17
+ async function decode() {
18
+ const image = sharp(await inputBytes(),{failOn:"warning",limitInputPixels:MAX_PIXELS,pages:-1}).timeout({seconds:5});
19
+ try {
20
+ const meta = await image.metadata();
21
+ if ("image/" + meta.format !== process.argv[2]) throw new Error("encoded format does not match declared MIME");
22
+ if (meta.width * meta.height > MAX_PIXELS) throw new Error("decoded pixel limit exceeded");
23
+ // Full decode, without resizing or shrinking that could conceal corruption.
24
+ // No decoded pixels cross back into the agent process.
25
+ await image.raw().toBuffer();
26
+ } finally { image.destroy(); }
27
+ }
28
+
29
+ try { await decode(); }
30
+ catch (error) { process.stderr.write(String(error.message).slice(0,2048)); process.exitCode = 1; }