pi-supernova 0.3.2 → 0.5.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.
@@ -1,21 +1,32 @@
1
1
  import { isFunction } from "../shared/decode.js";
2
2
 
3
3
  const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
4
+
4
5
  const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
5
6
 
6
7
  export function isMutatingTool(name, config = {}, args = {}, definition) {
7
8
  if ((config.mutatingTools ?? []).includes(name)) return true;
9
+
8
10
  if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
11
+
9
12
  if (definition?.annotations?.readOnlyHint === true) return false;
13
+
10
14
  if (name === "lsp") {
11
15
  const action = args.action ?? args.operation;
16
+
12
17
  if (READ_ONLY_LSP.has(action)) return false;
18
+
13
19
  if (["rename", "rename_file"].includes(action)) return args.apply !== false;
20
+
14
21
  if (action === "code_actions") return args.apply === true;
22
+
15
23
  return true;
16
24
  }
25
+
17
26
  if (name === "todo") return args.op !== "view";
27
+
18
28
  if (name === "hub") return !["list", "ps", "logs", "describe"].includes(args.op);
29
+
19
30
  return !READ_ONLY_TOOLS.has(name);
20
31
  }
21
32
 
@@ -30,22 +41,28 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
30
41
  try {
31
42
  while (queue.length) {
32
43
  const first = queue.shift();
44
+
33
45
  if (first.cancelled) continue;
34
46
  const wave = [first];
47
+
35
48
  if (first.name === "read") {
36
49
  while (wave.length < maxParallelReads && queue[0]?.name === "read") {
37
50
  const next = queue.shift();
51
+
38
52
  if (!next.cancelled) wave.push(next);
39
53
  }
54
+
40
55
  stats.readWaves++;
41
56
  stats.peakParallelReads = Math.max(stats.peakParallelReads, wave.length);
42
57
  }
58
+
43
59
  // Settle each promise separately: one failed read must not discard its
44
60
  // siblings or release a write barrier while other reads are still active.
45
61
  await Promise.all(wave.map(async job => {
46
62
  if (job.cancelled) return;
47
63
  job.started = true;
48
64
  job.signal?.removeEventListener("abort", job.abort);
65
+
49
66
  try {
50
67
  job.signal?.throwIfAborted();
51
68
  const result = await job.run();
@@ -63,8 +80,10 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
63
80
  if (!["read", "edit", "write", "bash"].includes(name) || !isFunction(run)) {
64
81
  return Promise.reject(new Error("scheduler requires a native tool and an executor"));
65
82
  }
83
+
66
84
  if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
67
85
  stats.calls++;
86
+
68
87
  return new Promise((resolve, reject) => {
69
88
  const job = { name, run, signal, resolve, reject, started: false, cancelled: false };
70
89
  job.abort = () => {
@@ -73,8 +92,10 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
73
92
  signal.removeEventListener("abort", job.abort);
74
93
  reject(signal.reason ?? new Error("aborted"));
75
94
  };
95
+
76
96
  signal?.addEventListener("abort", job.abort, { once: true });
77
97
  queue.push(job);
98
+
78
99
  if (!draining) {
79
100
  draining = true;
80
101
  // Pi submits sibling tools in the same turn without model-side code.
@@ -88,25 +109,34 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
88
109
 
89
110
  function requireArray(value, name) {
90
111
  if (!Array.isArray(value)) throw new TypeError(name + " requires an array");
112
+
91
113
  return value;
92
114
  }
93
115
 
94
116
  export async function runParallelWave(thunks, meta, options = {}) {
95
117
  const list = requireArray(thunks, "parallel wave");
118
+
96
119
  if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
120
+
97
121
  if (!list.length) return { results: [], mode: "serial", reason: "empty" };
98
122
  const { mode = "auto", config = {} } = options;
99
123
  const names = meta?.names ?? [];
100
124
  const mutating = names.length !== list.length || names.some((name, i) => isMutatingTool(name, config, meta?.calls?.[i]?.args, meta?.definitions?.[i]));
125
+
101
126
  if (!mutating && (mode === "parallel" || (mode === "auto" && list.length > 1))) {
102
127
  // Do not finish a wave while its already-started host calls are still running.
103
128
  const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
104
129
  const failure = settled.find(item => item.status === "rejected");
130
+
105
131
  if (failure) throw failure.reason;
132
+
106
133
  return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
107
134
  }
135
+
108
136
  const results = [];
137
+
109
138
  for (const thunk of list) results.push(await thunk());
139
+
110
140
  return { results, mode: "serial", reason: mutating ? "mutating" : "single-or-forced" };
111
141
  }
112
142
 
@@ -116,7 +146,10 @@ export async function parallel(items) {
116
146
 
117
147
  export async function pipeline(items, ...stages) {
118
148
  let current = requireArray(items, "pipeline");
149
+
119
150
  if (stages.some(stage => !isFunction(stage))) throw new TypeError("pipeline stages must be functions");
151
+
120
152
  for (const stage of stages) current = await Promise.all(current.map(item => stage(item)));
153
+
121
154
  return current;
122
155
  }
@@ -0,0 +1,112 @@
1
+ import { isObject, isString } from "../shared/decode.js";
2
+ import { truncateChars } from "../output/format.js";
3
+
4
+ const textOf = result => result.content.filter(block => block.type === "text").map(block => block.text).join("\n");
5
+
6
+ const mutationTotals = results => results.reduce((total, result) => {
7
+ const m = result.details?.mutations;
8
+
9
+ for (const key of ["committed","rolledBack","external","pendingCommits"]) total[key] += m?.[key] ?? 0;
10
+ total.recoveryFailed ||= !m || m.recoveryFailed === true;
11
+
12
+ return total;
13
+ }, {committed:0,rolledBack:0,external:0,pendingCommits:0,recoveryFailed:false});
14
+
15
+ export function programBatchText(results, total, stopped = "") {
16
+ const m = mutationTotals(results);
17
+
18
+ return (stopped ? "error: programs stopped: " + stopped : "ok: programs") + " " + results.length + "/" + total +
19
+ (m.recoveryFailed || m.pendingCommits ? "; filesystem outcome uncertain: inspect disk" : "") + "\nresults (UTF-16 lengths):\n" + results.map((result,i) => {
20
+ const text = textOf(result);
21
+
22
+ return "[" + i + "] " + text.length + "\n" + text + "\n";
23
+ }).join("");
24
+ }
25
+
26
+ function batchInputs(params, config) {
27
+ if (["code","file","data"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code, file or data; no programs ran");
28
+
29
+ if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
30
+
31
+ for (const p of params.programs) {
32
+ if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
33
+ ((p.code === undefined) === (p.file === undefined)) || !isString(p.code ?? p.file) || !(p.code ?? p.file).trim()) {
34
+ throw new Error("each program requires code OR file, with optional data; no nested batches or per-entry timeouts; no programs ran");
35
+ }
36
+ }
37
+
38
+ let encoded;
39
+
40
+ try { encoded = JSON.stringify(params.programs); } catch { throw new Error("programs must be JSON-serializable; no programs ran"); }
41
+
42
+ if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
43
+
44
+ return JSON.parse(encoded);
45
+ }
46
+
47
+ /** Explicit known continuations, not inferred plans, retries, or a shared heap. */
48
+ export async function runProgramBatch(id, params, signal, onUpdate, ctx, config, execute) {
49
+ const programs = batchInputs(params,config);
50
+ const started = performance.now();
51
+ const timeout = Number.isInteger(params.timeoutMs) ? params.timeoutMs : config.timeoutMs;
52
+ const deadline = started + timeout;
53
+ const controller = new AbortController();
54
+ const combined = signal ? AbortSignal.any([signal,controller.signal]) : controller.signal;
55
+ const timer = setTimeout(() => controller.abort(),Math.min(timeout,2147483647));
56
+ const budget = {calls:0,logLines:0};
57
+ const results = [], images = [], imageLabels = [], trace = [];
58
+ const imageTextChars = () => imageLabels.reduce((chars,label)=>chars+label.length+1,0);
59
+ let imageBytes = 0, stopped = "";
60
+
61
+ try {
62
+ for (const [i, program] of programs.entries()) {
63
+ if (combined.aborted || performance.now() >= deadline) { stopped = "deadline or cancellation; remaining programs did not run"; break; }
64
+
65
+ let result;
66
+
67
+ try {
68
+ result = await execute(id + ":" + i,{...program,timeoutMs:Math.max(1,Math.ceil(deadline-performance.now()))},combined,update => {
69
+ try { onUpdate?.({...update,details:{...update.details,trace:[...trace,...(update.details?.trace ?? [])]}}); } catch {}
70
+ },ctx,budget);
71
+ } catch (error) {
72
+ result = error.supernovaResult ?? {content:[{type:"text",text:String(error.message ?? error)}],details:{ok:false,error:String(error.message ?? error)}};
73
+ }
74
+
75
+ results.push(result);
76
+ trace.push(...(result.details?.trace ?? []));
77
+ let image = 0;
78
+
79
+ for (const block of result.content) if (block.type === "image") {
80
+ imageBytes += Buffer.byteLength(block.data,"base64");
81
+
82
+ if (images.length >= 16 || imageBytes > 20*1024*1024) { stopped = "batch image budget exceeded; remaining programs did not run"; break; }
83
+
84
+ images.push(block);
85
+ imageLabels.push("program " + (i+1) + " image " + (++image));
86
+ }
87
+
88
+ if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
89
+ const text = programBatchText(results,programs.length,stopped);
90
+
91
+ if (text.length + imageTextChars() > config.maxReturnChars || result.details?.returnTruncated) stopped ||= "batch output budget exceeded; remaining programs did not run; earlier commits remain";
92
+
93
+ if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
94
+
95
+ if (combined.aborted || performance.now() >= deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
96
+
97
+ if (stopped) break;
98
+ }
99
+ } finally { clearTimeout(timer); controller.abort(); }
100
+
101
+ const bounded = truncateChars(programBatchText(results,programs.length,stopped),Math.max(0,config.maxReturnChars-imageTextChars()),"batch output");
102
+ const content = [{type:"text",text:bounded.text}];
103
+ images.forEach((image,i) => content.push({type:"text",text:imageLabels[i]},image));
104
+
105
+ // Return a typed stop report instead of throwing away earlier results/images.
106
+ // Single-program errors retain their existing throwing behavior.
107
+ return {content,isError:!!stopped,details:{ok:!stopped,error:stopped || undefined,wallMs:Math.round(performance.now()-started),
108
+ programs:results,attempted:results.length,total:programs.length,stopped,
109
+ result:bounded.truncated ? bounded.text : results.map(result=>result.details?.result),
110
+ returnTruncated:bounded.truncated || results.some(result=>result.details?.returnTruncated),
111
+ logTruncated:results.some(result=>result.details?.logTruncated),logs:results.flatMap(result=>result.details?.logs ?? []),trace,mutations:mutationTotals(results)}};
112
+ }
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { resolveWorkspacePath } from "../fs/workspace.js";
3
+
4
+ /** Explicit source reuse, never a cached program or a persistent guest heap. */
5
+ export async function readProgramFile(file, cwd, maxChars, signal) {
6
+ signal?.throwIfAborted();
7
+ const target = await resolveWorkspacePath(cwd, file, "program file", false, true);
8
+ // A FIFO must fail without waiting for a writer or occupying an I/O worker.
9
+ const handle = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
10
+
11
+ try {
12
+ signal?.throwIfAborted();
13
+ const stat = await handle.stat();
14
+
15
+ if (!stat.isFile()) throw new Error("program file requires a regular file");
16
+ // Every UTF-16 unit needs at most three UTF-8 bytes. Also check streamed size:
17
+ // another editor can grow the file after stat. No prefix-only execution.
18
+ const maxBytes = maxChars * 3;
19
+ const tooLarge = () => new Error("code exceeds " + maxChars + " characters; split the program");
20
+
21
+ if (stat.size > maxBytes) throw tooLarge();
22
+ const chunks = [];
23
+ let bytes = 0;
24
+
25
+ for await (const chunk of handle.createReadStream({ end: maxBytes, autoClose: false, signal })) {
26
+ bytes += chunk.length;
27
+
28
+ if (bytes > maxBytes) throw tooLarge();
29
+ chunks.push(chunk);
30
+ }
31
+
32
+ signal?.throwIfAborted();
33
+ // Do not silently replace invalid bytes in executable source. Preserve BOMs.
34
+ const code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
35
+
36
+ if (code.length > maxChars) throw tooLarge();
37
+
38
+ return code;
39
+ } finally { await handle.close(); }
40
+ }
@@ -0,0 +1,18 @@
1
+ // Complete model-facing API reference; kept in every request, not moved into history.
2
+ export const REFERENCE = `JavaScript async body or arrow with read, write, edit, bash. Strings stay raw. Exactly one of code or file; file rereads that program (same limits, cwd, fresh guest). Caps are UTF-16. Put Markdown/scripts/argv in data.
3
+
4
+ read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries
5
+ read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP)
6
+ read({path,json:".field"}) → parsed JSON; .items[0:3], quoted keys, selector arrays, or true; 16 MiB cap; no jq
7
+ read("agent://id?q=.answer") → JSON field from session artifacts
8
+ read("symbol or question") → same view as resolve:true
9
+ read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
10
+ read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
11
+ write(path, text) → replace; write({path,content,append:true}) → append without a prior read
12
+ edit(path,oldText,newText) | edit({path,edits}) | edit({path,patch}) → numbered post-edit lines, checks, references
13
+ edit(async () => {...}) → checkpoint: commit on success, rollback on throw; no shell, nesting, or outside commands
14
+ bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
15
+ bash({command,args}) → literal argv, no shell expansion of args
16
+
17
+ edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file; uncertain returns ambiguous, not_found, or incomplete. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files; use about or offset/limit for large audits. Array reads reject failures; Promise.allSettled for per-path outcomes. Edits stage until success; bash commits preceding writes.
18
+ programs:[{code|file,data?},...] sequential fresh guests, separate commits; stop on failure keeps earlier commits. Separate calls when the next step needs a model decision.`;
@@ -1,4 +1,5 @@
1
1
  import { Worker } from "node:worker_threads";
2
+ import { readProgramFile } from "./program-file.js";
2
3
  import { parse } from "acorn";
3
4
  import { performance } from "node:perf_hooks";
4
5
  import { packageFinalReturn } from "../output/bottleneck.js";
@@ -6,20 +7,30 @@ import { truncateChars } from "../output/format.js";
6
7
  import { isFunction, isObject, isString } from "../shared/decode.js";
7
8
 
8
9
  const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
10
+
9
11
  const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
12
+
10
13
  const MEMORY_POLL_MS = 50;
14
+
11
15
  const MEMORY_SLACK = 1.5;
16
+
12
17
  const rssBytes = isFunction(process.memoryUsage?.rss) ? () => process.memoryUsage.rss() : () => process.memoryUsage().rss;
18
+
13
19
  let idleWorker = null;
20
+
14
21
  let runSeq = 0;
15
22
 
16
23
  const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
24
+
17
25
  const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
18
26
 
19
27
  function hasReturn(node) {
20
28
  if (!isObject(node)) return false;
29
+
21
30
  if (node.type === "ReturnStatement") return true;
31
+
22
32
  if (FUNCTION_TYPES.has(node.type)) return false;
33
+
23
34
  return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
24
35
  }
25
36
 
@@ -27,27 +38,34 @@ function prepareProgram(code) {
27
38
  let program;
28
39
  let expression;
29
40
  let expressionSource;
41
+
30
42
  try {
31
43
  program = parse(code, PARSE_OPTIONS);
32
44
  const statements = program.body.filter(node => node.type !== "EmptyStatement");
33
45
  const statement = statements.length === 1 ? statements[0] : undefined;
34
46
  const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
47
+
35
48
  if (candidate && FUNCTION_TYPES.has(candidate.type)) {
36
49
  expression = candidate;
37
50
  expressionSource = code.slice(0, statement.end).replace(/;\s*$/, "");
38
51
  }
39
52
  } catch (bodyError) {
40
53
  expressionSource = code.trimEnd().replace(/;+\s*$/, "");
54
+
41
55
  try {
42
56
  const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
43
57
  expression = wrapped.body[0]?.expression;
58
+
44
59
  if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
45
60
  } catch { throw bodyError; }
46
61
  }
62
+
47
63
  const body = expression ? "return await (" + expressionSource + "\n)();" : code;
64
+
48
65
  const returns = expression
49
66
  ? expression.type === "ArrowFunctionExpression" && expression.body.type !== "BlockStatement" || hasReturn(expression.body)
50
67
  : hasReturn(program);
68
+
51
69
  return { body, hasReturn: returns };
52
70
  }
53
71
 
@@ -62,6 +80,7 @@ function spawnWorker(config) {
62
80
  worker.on("error", () => { handle.dead = true; });
63
81
  worker.on("exit", () => {
64
82
  handle.dead = true;
83
+
65
84
  if (idleWorker === handle) idleWorker = null;
66
85
  });
67
86
  handle.ready = new Promise((resolve, reject) => {
@@ -70,27 +89,33 @@ function spawnWorker(config) {
70
89
  worker.off("error", onFail);
71
90
  worker.off("exit", onFail);
72
91
  };
92
+
73
93
  const onMessage = (msg) => {
74
94
  if (msg?.op !== "ready") return;
75
95
  cleanup();
76
96
  resolve();
77
97
  };
98
+
78
99
  const onFail = (err) => {
79
100
  cleanup();
80
101
  reject(err instanceof Error ? err : new Error("guest worker exited before ready (code " + err + ")"));
81
102
  };
103
+
82
104
  worker.on("message", onMessage);
83
105
  worker.on("error", onFail);
84
106
  worker.on("exit", onFail);
85
107
  });
86
108
  handle.ready.catch(() => {});
109
+
87
110
  return handle;
88
111
  }
89
112
 
90
113
  function killWorker(handle) {
91
114
  if (!handle) return;
115
+
92
116
  if (idleWorker === handle) idleWorker = null;
93
117
  handle.dead = true;
118
+
94
119
  return handle.worker.terminate();
95
120
  }
96
121
 
@@ -98,9 +123,11 @@ function acquireWorker(config) {
98
123
  const candidate = idleWorker;
99
124
  idleWorker = null;
100
125
  const reusable = candidate && !candidate.dead && candidate.maxHeapMb === (config.maxHeapMb ?? 512);
126
+
101
127
  if (candidate && !reusable) void killWorker(candidate);
102
128
  const handle = reusable ? candidate : spawnWorker(config);
103
129
  handle.worker.ref?.();
130
+
104
131
  return handle;
105
132
  }
106
133
 
@@ -112,6 +139,7 @@ export function warmGuestWorker(config = {}) {
112
139
  handle.ready.then(() => {
113
140
  if (idleWorker === handle) handle.worker.unref?.();
114
141
  }, () => {});
142
+
115
143
  return handle.ready;
116
144
  }
117
145
 
@@ -123,23 +151,38 @@ const RPC_METHODS = {
123
151
  call: (nova, args) => nova.call(args[0], args[1]),
124
152
  callMany: async (nova, args) => {
125
153
  const wave = await nova.callMany(args[0]);
154
+
126
155
  return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
127
156
  },
128
- search: (nova, args) => nova.search(args[0], args[1]),
129
- describe: (nova, args) => nova.describe(args[0]),
130
157
  speculateBegin: (nova) => nova.speculateBegin(),
131
158
  speculateCommit: (nova) => nova.speculateCommit(),
132
159
  speculateRollback: (nova) => nova.speculateRollback(),
133
160
  };
134
161
 
135
- export async function runGuestProgram({ code, nova = {}, config = {}, signal, onTimeout }) {
162
+ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, nova = {}, config = {}, signal, onTimeout }) {
136
163
  const started = performance.now();
137
164
  const wall = () => Math.round(performance.now() - started);
138
165
  const logs = [];
139
166
  const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
140
167
  let logTruncated = false;
141
- if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
142
- if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters; split large writes into write({path,content,append:true}) chunks");
168
+
169
+ if ((code === undefined) === (file === undefined)) return fail("supply exactly one of code or file; no commands ran");
170
+
171
+ if (file === undefined && (!isString(code) || !code.trim())) return fail("code must be a non-empty string");
172
+
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");
174
+
175
+ if (data !== undefined) {
176
+ try {
177
+ const encoded = JSON.stringify(data);
178
+
179
+ if (encoded === undefined) return fail("data must be JSON-serializable");
180
+
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
+ }
185
+
143
186
  if (signal?.aborted) return fail(ABORT_MESSAGE);
144
187
  const runId = ++runSeq;
145
188
  const timeoutMs = config.timeoutMs ?? 60000;
@@ -153,7 +196,10 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
153
196
  let hostError;
154
197
  let notifyingHost = false;
155
198
  const pending = new Set();
199
+ const inputController = new AbortController();
200
+
156
201
  const cleanup = () => {
202
+ inputController.abort();
157
203
  clearTimeout(timer);
158
204
  clearInterval(memTimer);
159
205
  signal?.removeEventListener("abort", signalAbort);
@@ -161,6 +207,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
161
207
  handle?.worker.off("error", onError);
162
208
  handle?.worker.off("exit", onExit);
163
209
  };
210
+
164
211
  const finish = (outcome) => {
165
212
  if (finished) return;
166
213
  finished = true;
@@ -169,27 +216,37 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
169
216
  void killWorker(handle);
170
217
  resolve({ ...outcome, wallMs: wall() });
171
218
  };
219
+
172
220
  const cancelHost = () => {
173
221
  notifyingHost = true;
222
+
174
223
  try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
175
224
  };
225
+
176
226
  const abort = () => {
177
227
  if (finished) return;
178
228
  cancelHost();
229
+
179
230
  try { onTimeout?.(); } catch {}
231
+
180
232
  finish(fail(ABORT_MESSAGE));
181
233
  };
234
+
182
235
  const signalAbort = () => { if (!notifyingHost) abort(); };
236
+
183
237
  const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
238
+
184
239
  const memTimer = setInterval(() => {
185
240
  if (rssBytes() <= rssLimit) return;
186
241
  cancelHost();
187
242
  finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
188
243
  }, MEMORY_POLL_MS);
244
+
189
245
  signal?.addEventListener("abort", signalAbort, { once: true });
190
246
 
191
247
  const postResult = (message) => {
192
248
  if (!accepting || finished) return;
249
+
193
250
  try {
194
251
  handle.worker.postMessage({ ...message, runId });
195
252
  } catch (err) {
@@ -200,6 +257,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
200
257
  }
201
258
  }
202
259
  };
260
+
203
261
  const complete = async (outcome) => {
204
262
  if (finished || completing) return;
205
263
  completing = true;
@@ -208,15 +266,21 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
208
266
  handle?.worker.off("error", onError);
209
267
  handle?.worker.off("exit", onExit);
210
268
  void killWorker(handle);
269
+
211
270
  if (!outcome.ok) cancelHost();
212
271
  await Promise.allSettled(pending);
272
+
213
273
  if (finished) return;
214
274
  finish(outcome.ok && hostError ? fail(hostError) : outcome);
215
275
  };
276
+
216
277
  const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
278
+
217
279
  const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
280
+
218
281
  const onMessage = (msg) => {
219
282
  if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
283
+
220
284
  if (msg.op === "log") {
221
285
  if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
222
286
  else logTruncated = true;
@@ -225,10 +289,13 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
225
289
  logTruncated = true;
226
290
  } else if (msg.op === "rpc") {
227
291
  const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
292
+
228
293
  const work = Promise.resolve().then(() => {
229
294
  if (!method) throw new Error("unknown nova method: " + msg.method);
295
+
230
296
  return method(nova, msg.args);
231
297
  });
298
+
232
299
  pending.add(work);
233
300
  work.then(
234
301
  value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
@@ -256,23 +323,38 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
256
323
  void (async () => {
257
324
  try {
258
325
  if (signal?.aborted) return abort();
326
+
327
+ if (file !== undefined) code = await readProgramFile(file, cwd, config.maxCodeChars ?? 48000, inputController.signal);
328
+
329
+ if (finished) return;
330
+
331
+ if (!code.trim()) return finish(fail("code must be a non-empty string; no commands ran"));
332
+ let prepared;
333
+
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}).")); }
336
+
337
+ if (wall() >= timeoutMs) return abort();
259
338
  handle = acquireWorker(config);
260
339
  await handle.ready;
340
+
261
341
  if (finished || signal?.aborted) return abort();
262
342
  const available = isFunction(nova.names) ? await nova.names() : [];
343
+
263
344
  if (finished || signal?.aborted) return abort();
264
345
  handle.worker.on("message", onMessage);
265
346
  handle.worker.on("error", onError);
266
347
  handle.worker.on("exit", onExit);
267
- const prepared = prepareProgram(code);
348
+
268
349
  if (wall() >= timeoutMs) return abort();
269
- handle.worker.postMessage({ op: "run", runId, prepared, available,
350
+ handle.worker.postMessage({ op: "run", runId, prepared, data, available,
270
351
  batchRead: nova.batchRead !== false,
271
352
  nativeArgv: nova.nativeArgv === true,
272
353
  limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
273
354
  } catch (err) {
355
+ if (finished) return;
274
356
  cancelHost();
275
- finish(fail("guest worker failed to start: " + err.message));
357
+ finish(fail("program failed to start: " + err.message + "; no commands ran"));
276
358
  }
277
359
  })();
278
360
  });