pi-supernova 0.3.2 → 0.4.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,25 @@
1
+ // Complete model-facing API reference; kept in every request, not moved into history.
2
+ export const REFERENCE = `Run a JavaScript async body or arrow with read, write, edit and bash. Strings stay raw. Single program: exactly one of code or file; file rereads a workspace program with the same limits, bindings, calling-workspace cwd and fresh guest. Code and data character caps use UTF-16 units. Split large writes using append.
3
+
4
+ Native commands (async):
5
+ read(path|paths, offset?, limit?) → file text or text[]; read(directory) → directory entries
6
+ read(imagePath) → image attachment when returned (PNG/JPEG/GIF/WebP/BMP); no browser needed
7
+ read({path,json:".field"}) → parsed JSON field; supports .items[0:3], quoted keys, selector arrays, or true for the whole JSON value
8
+ read("agent://id?q=.answer") → JSON field from a calling-session resource (when host artifacts are available)
9
+ read("symbol or question") → locate and open source in one call, without an index; selected file text stays raw
10
+ read({query, resolve:true}) → {status,path,line,lines,text,complete,nextOffset?} for a direct resolve→edit handoff
11
+ read(path, {about: question}) → relevant file bodies, or source selection inside a directory
12
+ read({query, evidence:true}) → ranked evidence; read({path, outline:true}) → structural declarations
13
+ write(path, text) → write a file; write({path,content,append:true}) appends a chunk without a bounded read
14
+ edit(path, oldText, newText) → post-edit lines, checks, and references
15
+ edit(async () => {...}) → filesystem checkpoint: commit on success, rollback on throw; no shell commands, nesting, or concurrent outside commands
16
+ bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
17
+ bash({command, args:[...]}) → literal argv without shell expansion of arguments
18
+
19
+ Start with a source question or scoped about read; do not redundantly reopen selected source. Only found selects and opens a file. Uncertain reads return ambiguous, not_found, or incomplete with no selected path. Use resolve:true to check status before editing its path; narrow the directory with path+about when uncertain.
20
+ For read-modify-write, use read({path,complete:true}); it rejects partial output. JSON selectors parse the full document (16 MiB cap); selections must fit the read budget. No full jq; do not JSON.parse line windows. For large text audits use about or offset/limit, not complete:true. Prefer edit for large files. Array reads reject failures; use Promise.allSettled for per-path outcomes.
21
+ Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?, complete?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
22
+ File changes stage until program success; later errors roll them back. Shell calls commit preceding writes and cannot be rolled back. Outcomes report committed/rolledBack file versions and external-call attempts.
23
+ Independent read starts batch automatically. Mutations preserve submission order. Plain reads remain self-contained; oversized reads provide continuation offsets. Return only what the model needs. console.log is captured.
24
+
25
+ For known continuations use programs:[{code|file,data?},...]. Entries run sequentially in fresh guests with separate commits. The batch stops on failure and returns all attempted results, including a typed stop report; earlier commits remain. Deadlines, host calls, logs and output are shared across the batch. Use separate calls when the next action needs model reasoning.`;
@@ -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,6 +151,7 @@ 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
157
  search: (nova, args) => nova.search(args[0], args[1]),
@@ -132,14 +161,30 @@ const RPC_METHODS = {
132
161
  speculateRollback: (nova) => nova.speculateRollback(),
133
162
  };
134
163
 
135
- export async function runGuestProgram({ code, nova = {}, config = {}, signal, onTimeout }) {
164
+ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, nova = {}, config = {}, signal, onTimeout }) {
136
165
  const started = performance.now();
137
166
  const wall = () => Math.round(performance.now() - started);
138
167
  const logs = [];
139
168
  const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
140
169
  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");
170
+
171
+ if ((code === undefined) === (file === undefined)) return fail("supply exactly one of code or file; no commands ran");
172
+
173
+ if (file === undefined && (!isString(code) || !code.trim())) return fail("code must be a non-empty string");
174
+
175
+ 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");
176
+
177
+ if (data !== undefined) {
178
+ try {
179
+ const encoded = JSON.stringify(data);
180
+
181
+ if (encoded === undefined) return fail("data must be JSON-serializable");
182
+
183
+ if (encoded.length > (config.maxCodeChars ?? 48000)) return fail("data exceeds " + (config.maxCodeChars ?? 48000) + " characters; split literal inputs across invocations");
184
+ data = JSON.parse(encoded);
185
+ } catch { return fail("data must be JSON-serializable"); }
186
+ }
187
+
143
188
  if (signal?.aborted) return fail(ABORT_MESSAGE);
144
189
  const runId = ++runSeq;
145
190
  const timeoutMs = config.timeoutMs ?? 60000;
@@ -153,7 +198,10 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
153
198
  let hostError;
154
199
  let notifyingHost = false;
155
200
  const pending = new Set();
201
+ const inputController = new AbortController();
202
+
156
203
  const cleanup = () => {
204
+ inputController.abort();
157
205
  clearTimeout(timer);
158
206
  clearInterval(memTimer);
159
207
  signal?.removeEventListener("abort", signalAbort);
@@ -161,6 +209,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
161
209
  handle?.worker.off("error", onError);
162
210
  handle?.worker.off("exit", onExit);
163
211
  };
212
+
164
213
  const finish = (outcome) => {
165
214
  if (finished) return;
166
215
  finished = true;
@@ -169,27 +218,37 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
169
218
  void killWorker(handle);
170
219
  resolve({ ...outcome, wallMs: wall() });
171
220
  };
221
+
172
222
  const cancelHost = () => {
173
223
  notifyingHost = true;
224
+
174
225
  try { nova.cancel?.(); } catch {} finally { notifyingHost = false; }
175
226
  };
227
+
176
228
  const abort = () => {
177
229
  if (finished) return;
178
230
  cancelHost();
231
+
179
232
  try { onTimeout?.(); } catch {}
233
+
180
234
  finish(fail(ABORT_MESSAGE));
181
235
  };
236
+
182
237
  const signalAbort = () => { if (!notifyingHost) abort(); };
238
+
183
239
  const timer = setTimeout(abort, Math.min(timeoutMs, 2147483647));
240
+
184
241
  const memTimer = setInterval(() => {
185
242
  if (rssBytes() <= rssLimit) return;
186
243
  cancelHost();
187
244
  finish(fail("guest exceeded memory limit (maxHeapMb=" + (config.maxHeapMb ?? 512) + ")"));
188
245
  }, MEMORY_POLL_MS);
246
+
189
247
  signal?.addEventListener("abort", signalAbort, { once: true });
190
248
 
191
249
  const postResult = (message) => {
192
250
  if (!accepting || finished) return;
251
+
193
252
  try {
194
253
  handle.worker.postMessage({ ...message, runId });
195
254
  } catch (err) {
@@ -200,6 +259,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
200
259
  }
201
260
  }
202
261
  };
262
+
203
263
  const complete = async (outcome) => {
204
264
  if (finished || completing) return;
205
265
  completing = true;
@@ -208,15 +268,21 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
208
268
  handle?.worker.off("error", onError);
209
269
  handle?.worker.off("exit", onExit);
210
270
  void killWorker(handle);
271
+
211
272
  if (!outcome.ok) cancelHost();
212
273
  await Promise.allSettled(pending);
274
+
213
275
  if (finished) return;
214
276
  finish(outcome.ok && hostError ? fail(hostError) : outcome);
215
277
  };
278
+
216
279
  const onError = (err) => { void complete(fail("guest crashed: " + err.message)); };
280
+
217
281
  const onExit = (exitCode) => { void complete(fail("guest exited (code " + exitCode + ")")); };
282
+
218
283
  const onMessage = (msg) => {
219
284
  if (finished || !accepting || !isObject(msg) || msg.runId !== runId) return;
285
+
220
286
  if (msg.op === "log") {
221
287
  if (logs.length < (config.maxLogLines ?? 100)) logs.push(msg.line);
222
288
  else logTruncated = true;
@@ -225,10 +291,13 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
225
291
  logTruncated = true;
226
292
  } else if (msg.op === "rpc") {
227
293
  const method = Object.hasOwn(RPC_METHODS, msg.method) && RPC_METHODS[msg.method];
294
+
228
295
  const work = Promise.resolve().then(() => {
229
296
  if (!method) throw new Error("unknown nova method: " + msg.method);
297
+
230
298
  return method(nova, msg.args);
231
299
  });
300
+
232
301
  pending.add(work);
233
302
  work.then(
234
303
  value => postResult({ op: "rpc:result", id: msg.id, ok: true, value }),
@@ -256,23 +325,38 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
256
325
  void (async () => {
257
326
  try {
258
327
  if (signal?.aborted) return abort();
328
+
329
+ if (file !== undefined) code = await readProgramFile(file, cwd, config.maxCodeChars ?? 48000, inputController.signal);
330
+
331
+ if (finished) return;
332
+
333
+ if (!code.trim()) return finish(fail("code must be a non-empty string; no commands ran"));
334
+ let prepared;
335
+
336
+ try { prepared = prepareProgram(code); }
337
+ 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}).")); }
338
+
339
+ if (wall() >= timeoutMs) return abort();
259
340
  handle = acquireWorker(config);
260
341
  await handle.ready;
342
+
261
343
  if (finished || signal?.aborted) return abort();
262
344
  const available = isFunction(nova.names) ? await nova.names() : [];
345
+
263
346
  if (finished || signal?.aborted) return abort();
264
347
  handle.worker.on("message", onMessage);
265
348
  handle.worker.on("error", onError);
266
349
  handle.worker.on("exit", onExit);
267
- const prepared = prepareProgram(code);
350
+
268
351
  if (wall() >= timeoutMs) return abort();
269
- handle.worker.postMessage({ op: "run", runId, prepared, available,
352
+ handle.worker.postMessage({ op: "run", runId, prepared, data, available,
270
353
  batchRead: nova.batchRead !== false,
271
354
  nativeArgv: nova.nativeArgv === true,
272
355
  limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
273
356
  } catch (err) {
357
+ if (finished) return;
274
358
  cancelHost();
275
- finish(fail("guest worker failed to start: " + err.message));
359
+ finish(fail("program failed to start: " + err.message + "; no commands ran"));
276
360
  }
277
361
  })();
278
362
  });