pi-supernova 0.8.1 → 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 +244 -54
  2. package/docs/CHANGELOG.md +111 -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 +23 -33
  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 +18 -2
  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 -242
  41. package/src/fs/vfs.js +5 -239
  42. package/src/fs/workspace.js +7 -2
  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 +35 -68
  52. package/src/runtime/program-file.js +3 -11
  53. package/src/runtime/program.js +141 -0
  54. package/src/runtime/reference.js +11 -10
  55. package/src/runtime/runtime.js +94 -261
  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,210 +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
-
13
- const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
12
+ import { errorContext } from "../shared/syntax-context.js";
13
+ import {validateReturnedImages} from "../shared/image.js";
14
14
 
15
- const MEMORY_POLL_MS = 50;
15
+ const ABORT_MESSAGE = "supernova aborted";
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";
16
17
 
17
18
  const MEMORY_SLACK = 1.5;
18
19
 
19
- const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
20
-
21
20
  const toMb = bytes => Math.max(0, bytes / 1048576);
22
21
 
23
- /** Pure attribution for a memory-limit trip: which operation, and how much of the RSS growth supernova can account for. */
24
- export function formatMemoryAttribution({ limitMb, rssBytes: now, startBytes, ms, op, calls, tracked }) {
25
- 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 }) {
26
24
  const parts = tracked
27
25
  ? [["vfs cache", tracked.vfsCacheBytes], ["index entries", tracked.indexBytes], ["overlays", tracked.overlayBytes]]
28
26
  : [];
29
- const trackedMb = parts.reduce((sum, [, bytes]) => sum + toMb(Number(bytes) || 0), 0);
30
- const untrackedMb = Math.max(0, deltaMb - trackedMb);
31
27
  const where = op ? ` during ${op} (${calls} host calls)` : ` (${calls} host calls)`;
32
28
  const seen = parts.length
33
- ? `; 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`
34
30
  : "";
35
31
 
36
- return `guest exceeded memory limit (maxHeapMb=${limitMb}): process RSS +${deltaMb.toFixed(1)}MB in ${Math.round(ms)}ms${where}${seen}; `
37
- + `~${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}`;
38
33
  }
39
34
 
40
- let idleWorker = null;
41
-
42
35
  let runSeq = 0;
43
36
 
44
- const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
45
-
46
- const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
47
-
48
- function hasReturn(node) {
49
- if (!isObject(node)) return false;
50
-
51
- if (node.type === "ReturnStatement") return true;
52
-
53
- if (FUNCTION_TYPES.has(node.type)) return false;
54
-
55
- return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
56
- }
57
-
58
- function parseExpressionFunction(code) {
59
- try {
60
- const program = parse(code, PARSE_OPTIONS);
61
- const statements = program.body.filter(node => node.type !== "EmptyStatement");
62
- const statement = statements.length === 1 ? statements[0] : undefined;
63
- const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
64
-
65
- if (candidate && FUNCTION_TYPES.has(candidate.type)) {
66
- return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
67
- }
68
-
69
- return { program };
70
- } catch (bodyError) {
71
- const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
72
-
73
- try {
74
- const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
75
- const expression = wrapped.body[0]?.expression;
76
-
77
- if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
78
-
79
- return { expression, expressionSource };
80
- } catch { throw bodyError; }
81
- }
82
- }
83
-
84
- function deniedSpecifier(node) {
85
- if (node?.type === "Literal" && isString(node.value)) return node.value;
86
- if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
87
- }
88
-
89
- function assertGuestImports(ast) {
90
- function walk(node) {
91
- if (Array.isArray(node)) { node.forEach(walk); return; }
92
- if (!isObject(node)) return;
93
- if (node.type === "ImportDeclaration" || node.type === "ImportExpression") {
94
- const spec = deniedSpecifier(node.source);
95
- throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
96
- }
97
- if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") {
98
- const spec = deniedSpecifier(node.arguments?.[0]);
99
- throw new Error((spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash") + "; no commands ran");
100
- }
101
- for (const key of Object.keys(node)) {
102
- if (key === "start" || key === "end" || key === "loc" || key === "range") continue;
103
- walk(node[key]);
104
- }
105
- }
106
-
107
- walk(ast);
108
- }
109
-
110
- function prepareProgram(code) {
111
- const parsed = parseExpressionFunction(code);
112
- assertGuestImports(parsed.program ?? parsed.expression);
113
- const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
114
- const returns = parsed.expression
115
- ? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
116
- : hasReturn(parsed.program);
117
-
118
- return { body, hasReturn: returns };
119
- }
120
-
121
- function spawnWorker(config) {
122
- const maxHeapMb = config.maxHeapMb ?? 512;
123
- // An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
124
- // Keep Node's automatic flag inheritance: explicitly copying execArgv can
125
- // reintroduce process-only V8 flags that Worker rejects under node --test.
126
- const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
127
- const handle = { worker, maxHeapMb, dead: false, ready: null };
128
- // This listener also owns errors between readiness and a run's listeners.
129
- worker.on("error", () => { handle.dead = true; });
130
- worker.on("exit", () => {
131
- handle.dead = true;
132
-
133
- if (idleWorker === handle) idleWorker = null;
134
- });
135
- handle.ready = new Promise((resolve, reject) => {
136
- const cleanup = () => {
137
- worker.off("message", onMessage);
138
- worker.off("error", onFail);
139
- worker.off("exit", onFail);
140
- };
141
-
142
- const onMessage = (msg) => {
143
- if (msg?.op !== "ready") return;
144
- cleanup();
145
- resolve();
146
- };
147
-
148
- const onFail = (err) => {
149
- cleanup();
150
- reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
151
- };
152
-
153
- worker.on("message", onMessage);
154
- worker.on("error", onFail);
155
- worker.on("exit", onFail);
156
- });
157
- handle.ready.catch(() => {});
158
-
159
- return handle;
160
- }
161
-
162
- function killWorker(handle) {
163
- if (!handle) return;
164
-
165
- if (idleWorker === handle) idleWorker = null;
166
- handle.dead = true;
167
-
168
- return handle.worker.terminate();
169
- }
170
-
171
- function acquireWorker(config) {
172
- const candidate = idleWorker;
173
- idleWorker = null;
174
- const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
175
-
176
- if (candidate && !reusable) void killWorker(candidate);
177
- const handle = reusable ? candidate : spawnWorker(config);
178
- handle.worker.ref?.();
179
-
180
- // Pipeline the successor while this run executes. A consumed worker's
181
- // replacement starts at once; a cold start's replacement waits for ready,
182
- // so the two constructions never overlap. The finish-time warm usually
183
- // becomes a no-op, so steady-state spawn count is unchanged.
184
- if (reusable) warmGuestWorker(config).catch(() => {});
185
- else handle.ready.then(() => warmGuestWorker(config).catch(() => {}), () => {});
186
-
187
- return handle;
188
- }
189
-
190
- /** Only pristine workers may be prewarmed. A used worker is never pooled. */
191
- export function warmGuestWorker(config = {}) {
192
- if (idleWorker && !idleWorker.dead) return idleWorker.ready;
193
- const handle = spawnWorker(config);
194
- idleWorker = handle;
195
- handle.ready.then(() => {
196
- if (idleWorker === handle) handle.worker.unref?.();
197
- }, () => {});
198
-
199
- return handle.ready;
200
- }
201
-
202
- export function stopWarmGuestWorker() {
203
- return killWorker(idleWorker);
204
- }
205
-
206
37
  const RPC_METHODS = {
207
- call: (nova, args) => nova.call(args[0], args[1]),
38
+ call: (nova, args, onItem) => nova.call(args[0], args[1], onItem),
208
39
  callMany: async (nova, args) => {
209
40
  const wave = await nova.callMany(args[0]);
210
41
 
@@ -215,50 +46,8 @@ const RPC_METHODS = {
215
46
  speculateRollback: (nova) => nova.speculateRollback(),
216
47
  };
217
48
 
218
- function admitData(data, cap) {
219
- if (data === undefined) return { data };
220
-
221
- try {
222
- const encoded = JSON.stringify(data);
223
-
224
- if (encoded === undefined) return { error: "data must be JSON-serializable" };
225
- 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" };
226
-
227
- return { data: JSON.parse(encoded) };
228
- } catch { return { error: "data must be JSON-serializable" }; }
229
- }
230
-
231
- function admitCode({ code, file, cap }) {
232
- if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
233
- if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
234
- if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
235
- }
236
-
237
- function admitTimeout(config) {
238
- const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
239
-
240
- if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
241
-
242
- return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
243
- }
244
-
245
- function admitGuest({ code, file, data, config }) {
246
- const cap = config.maxCodeChars ?? 48000;
247
- const codeError = admitCode({ code, file, cap });
248
-
249
- if (codeError) return codeError;
250
- const admitted = admitData(data, cap);
251
-
252
- if (admitted.error) return admitted;
253
- const timeout = admitTimeout(config);
254
-
255
- if (timeout.error) return timeout;
256
-
257
- return { data: admitted.data, timeoutMs: timeout.timeoutMs };
258
- }
259
-
260
49
  class GuestRun {
261
- 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 }) {
262
51
  this.code = code;
263
52
  this.file = file;
264
53
  this.cwd = cwd;
@@ -269,8 +58,6 @@ class GuestRun {
269
58
  this.onTimeout = onTimeout;
270
59
  this.runId = runId;
271
60
  this.timeoutMs = timeoutMs;
272
- this.rssLimit = rssLimit;
273
- this.rssStart = rssStart;
274
61
  this.started = performance.now();
275
62
  this.logs = [];
276
63
  this.logTruncated = false;
@@ -281,8 +68,14 @@ class GuestRun {
281
68
  this.hostError = undefined;
282
69
  this.notifyingHost = false;
283
70
  this.aborting = false;
71
+ this.abortOutcome = undefined;
284
72
  this.pending = new Set();
285
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});
286
79
  this.rpcCount = 0;
287
80
  this.lastRpcMethod = null;
288
81
  }
@@ -298,7 +91,6 @@ class GuestRun {
298
91
  cleanup() {
299
92
  this.inputController.abort();
300
93
  clearTimeout(this.timer);
301
- clearInterval(this.memTimer);
302
94
  this.signal?.removeEventListener("abort", this.signalAbort);
303
95
  this.handle?.worker.off("message", this.onMessage);
304
96
  this.handle?.worker.off("error", this.onError);
@@ -320,31 +112,57 @@ class GuestRun {
320
112
  try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
321
113
  }
322
114
 
323
- abort() {
115
+ abort(timedOut = false) {
324
116
  if (this.finished || this.aborting) return;
325
117
  this.aborting = true;
118
+ this.accepting = false;
119
+ this.abortOutcome = this.fail((timedOut ? TIMEOUT_MESSAGE : ABORT_MESSAGE) + " (ran " + this.wall() + "ms of " + this.timeoutMs + "ms)");
326
120
  this.cancelHost();
327
121
 
328
- try { this.onTimeout?.(); } catch {}
122
+ if (timedOut) { try { this.onTimeout?.(); } catch {} }
329
123
 
330
- this.aborting = false;
331
- this.finish(this.fail(ABORT_MESSAGE + " (ran " + Math.round(this.wall()) + "ms of " + this.timeoutMs + "ms)"));
124
+ // Stop the guest immediately, then use the bounded host drain to retain
125
+ // shell diagnostics and wait for process-tree termination before returning.
126
+ void this.complete(this.abortOutcome);
332
127
  }
333
128
 
334
129
  postResult(message) {
335
- if (!this.accepting || this.finished) return;
130
+ if (!this.accepting || this.finished) return false;
336
131
 
337
132
  try {
338
133
  this.handle.worker.postMessage({ ...message, runId: this.runId });
134
+ return true;
339
135
  } catch (err) {
340
136
  try {
341
137
  this.handle.worker.postMessage({ op: "rpc:result", id: message.id, runId: this.runId, ok: false, error: "result not transferable: " + err.message });
342
138
  } catch (error) {
343
139
  this.onError(error);
344
140
  }
141
+ return false;
345
142
  }
346
143
  }
347
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
+
348
166
  async drainPending(outcome) {
349
167
  if (this.pending.size || !outcome.ok) this.cancelHost();
350
168
  if (!this.pending.size) return;
@@ -360,11 +178,16 @@ class GuestRun {
360
178
  if (this.finished || this.completing) return;
361
179
  this.completing = true;
362
180
  this.accepting = false;
181
+ this.inputController.abort();
363
182
  this.handle?.worker.off("error", this.onError);
364
183
  this.handle?.worker.off("exit", this.onExit);
365
184
  void killWorker(this.handle);
366
185
  await this.drainPending(outcome);
367
186
  if (this.finished) return;
187
+ if (this.abortOutcome) {
188
+ const diagnostic = this.hostError && this.hostError !== "aborted" ? "\n" + this.hostError : "";
189
+ outcome = this.fail(this.abortOutcome.error + diagnostic);
190
+ }
368
191
  this.finish(outcome.ok && this.hostError ? this.fail(this.hostError) : outcome);
369
192
  }
370
193
 
@@ -385,39 +208,63 @@ class GuestRun {
385
208
  const work = Promise.resolve().then(() => {
386
209
  if (!method) throw new Error("unknown nova method: " + msg.method);
387
210
 
388
- 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);
389
214
  });
390
215
 
391
216
  this.pending.add(work);
392
217
  work.then(
393
218
  value => this.postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
394
219
  err => {
395
- if (!this.accepting) this.hostError ??= err instanceof Error ? err.message : String(err);
396
- 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) });
397
222
  },
398
223
  ).finally(() => this.pending.delete(work));
399
224
  }
400
225
 
401
- 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;
402
230
  try {
403
- 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);
404
233
  void this.complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
405
234
  returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
406
- logs: packed.logs, logTruncated: this.logTruncated || packed.logTruncated });
235
+ logs: this.logs, logTruncated: this.logTruncated || packed.logTruncated });
407
236
  } catch (err) {
408
237
  void this.complete(this.fail(err.message));
409
238
  }
410
239
  }
411
240
 
412
241
  guestError(msg) {
413
- 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 + ")" : "";
414
244
  void this.complete(this.fail(msg.message + where));
415
245
  }
416
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
+
417
262
  dispatchGuest(msg) {
418
263
  if (msg.op === "log") this.onLog(msg);
419
264
  else if (msg.op === "logTruncated") this.logTruncated = true;
265
+ else if (msg.op === "memory") this.onMemory(msg);
420
266
  else if (msg.op === "rpc") this.onRpc(msg);
267
+ else if (msg.op === "rpc:ack") this.onReadAck(msg);
421
268
  else if (msg.op === "done") this.onDone(msg);
422
269
  else if (msg.op === "error") this.guestError(msg);
423
270
  }
@@ -448,7 +295,7 @@ class GuestRun {
448
295
  }
449
296
 
450
297
  async attachWorker() {
451
- if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
298
+ if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
452
299
  this.handle = acquireWorker(this.config);
453
300
  await this.handle.ready;
454
301
  if (this.finished || this.signal?.aborted) { this.abort(); return false; }
@@ -459,7 +306,7 @@ class GuestRun {
459
306
  this.handle.worker.on("message", this.onMessage);
460
307
  this.handle.worker.on("error", this.onError);
461
308
  this.handle.worker.on("exit", this.onExit);
462
- if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
309
+ if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
463
310
  this.available = available;
464
311
 
465
312
  return true;
@@ -469,7 +316,8 @@ class GuestRun {
469
316
  this.handle.worker.postMessage({ op: "run", runId: this.runId, prepared: this.prepared, data: this.data, available: this.available,
470
317
  batchRead: this.nova.batchRead !== false,
471
318
  nativeArgv: this.nova.nativeArgv === true,
472
- 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 } });
473
321
  }
474
322
 
475
323
  async boot() {
@@ -488,20 +336,7 @@ class GuestRun {
488
336
  start() {
489
337
  return new Promise((resolve) => {
490
338
  this.resolve = resolve;
491
- this.timer = setTimeout(() => this.abort(), Math.min(this.timeoutMs, 2147483647));
492
- this.memTimer = setInterval(() => {
493
- const now = rssBytes();
494
-
495
- if (now <= this.rssLimit) return;
496
- this.cancelHost();
497
- let tracked = null;
498
-
499
- try { tracked = isFunction(this.nova?.describeMemory) ? this.nova.describeMemory() : null; } catch {}
500
- this.finish(this.fail(formatMemoryAttribution({
501
- limitMb: this.config.maxHeapMb ?? 512, rssBytes: now, startBytes: this.rssStart ?? now,
502
- ms: this.wall(), op: this.lastRpcMethod, calls: this.rpcCount, tracked,
503
- })));
504
- }, MEMORY_POLL_MS);
339
+ this.timer = setTimeout(() => this.abort(true), Math.min(this.timeoutMs, 2147483647));
505
340
  this.signal?.addEventListener("abort", this.signalAbort, { once: true });
506
341
  void this.boot();
507
342
  });
@@ -514,12 +349,10 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
514
349
 
515
350
  if (admitted.error) return fail(admitted.error);
516
351
  if (signal?.aborted) return fail(ABORT_MESSAGE);
517
- const rssStart = rssBytes();
518
352
 
519
353
  return new GuestRun({
520
354
  code, file, cwd, data: admitted.data, nova, config, signal, onTimeout,
521
355
  runId: ++runSeq,
522
356
  timeoutMs: admitted.timeoutMs,
523
- rssLimit: rssStart + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576, rssStart,
524
357
  }).start();
525
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
+ }