pi-supernova 0.4.0 → 0.6.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,6 +1,6 @@
1
1
  import { parentPort } from "node:worker_threads";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { isString, isObject, isFunction, toPlain } from "../shared/decode.js";
3
+ import { isString, isObject, isFunction, isNumber, toPlain, looksLikePath } from "../shared/decode.js";
4
4
  import { truncateChars } from "../output/format.js";
5
5
  import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
6
6
 
@@ -94,7 +94,11 @@ function leanEnvelope(res) {
94
94
  return res;
95
95
  }
96
96
 
97
- function buildGuestApi(available, batchRead, runId, nativeArgv) {
97
+ function quoteShellArg(value) {
98
+ return "'" + String(value).replaceAll("'", "'\\''") + "'";
99
+ }
100
+
101
+ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
98
102
  const rpc = (method, args) => callRpc(runId, method, args);
99
103
  const availableSet = new Set(available);
100
104
  const checkpointScope = new AsyncLocalStorage();
@@ -106,20 +110,41 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
106
110
  if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
107
111
  };
108
112
 
113
+ let operationTail = Promise.resolve();
114
+
115
+ const enqueueHost = operation => {
116
+ const next = operationTail.then(operation);
117
+ operationTail = next.then(() => {}, () => {});
118
+
119
+ return next;
120
+ };
121
+
109
122
  const nova = {
110
- search: (query, limit) => rpc("search", [query, limit]),
111
- describe: (name) => rpc("describe", [name]),
112
- call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
113
- async callMany(calls) {
114
- const wave = await rpc("callMany", [calls]);
115
- const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
116
- Object.defineProperties(results, {
117
- mode: { value: wave?.mode, enumerable: false },
118
- reason: { value: wave?.reason, enumerable: false },
119
- results: { value: results, enumerable: false },
123
+ call(name, args) {
124
+ assertScope(); flushReads();
125
+ const promise = enqueueHost(() => rpc("call", [name, args]).then(leanEnvelope));
126
+
127
+ promise.catch(() => {});
128
+
129
+ return promise;
130
+ },
131
+ callMany(calls) {
132
+ assertScope(); flushReads();
133
+ const promise = enqueueHost(async () => {
134
+ const wave = await rpc("callMany", [calls]);
135
+ const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
136
+ Object.defineProperties(results, {
137
+ mode: { value: wave?.mode, enumerable: false },
138
+ reason: { value: wave?.reason, enumerable: false },
139
+ results: { value: results, enumerable: false },
140
+ });
141
+
142
+ return results;
120
143
  });
121
144
 
122
- return results;
145
+ promise.catch(() => {});
146
+
147
+ return promise;
123
148
  },
124
149
  async speculate(fn) {
125
150
  if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
@@ -128,30 +153,31 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
128
153
  let began = false;
129
154
 
130
155
  try {
131
- flushReads();
132
- await rpc("speculateBegin", []);
156
+ await drainReads();
157
+ await enqueueHost(() => rpc("speculateBegin", []));
133
158
  began = true;
134
159
  const value = await checkpointScope.run(token, fn);
135
- flushReads();
136
- await rpc("speculateCommit", []);
160
+ await drainReads();
161
+ await enqueueHost(() => rpc("speculateCommit", []));
137
162
 
138
163
  return { ok: true, committed: true, value };
139
164
  } catch (err) {
140
- flushReads();
165
+ await drainReads();
141
166
 
142
- if (began) await rpc("speculateRollback", []);
167
+ if (began) await enqueueHost(() => rpc("speculateRollback", []));
143
168
 
144
169
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
145
170
  } finally { checkpoint = null; }
146
171
  },
147
- surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
148
- evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
149
- snap: async (query, targetPath) => unwrapJsonValue(await rpc("call", ["snap", { query, path: targetPath }])),
172
+ surface(filePath) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["surface", { path: filePath }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
173
+ evidence(query, opts) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["evidence", { query, ...opts }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
174
+ snap(query, targetPath) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["snap", { query, path: targetPath }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
150
175
  has: (name) => availableSet.has(name),
151
176
  };
152
177
 
153
178
  // Coalesce already-started compatible reads without rewriting JS control flow.
154
179
  let queuedReads = [];
180
+ const pendingReadWaves = new Set();
155
181
 
156
182
  function flushReads() {
157
183
  const pending = queuedReads;
@@ -161,13 +187,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
161
187
  const wave = pending.slice(start, start + 64);
162
188
  const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
163
189
 
164
- const run = wave.length === 1
165
- ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
166
- : nova.call("read", args).then(res => { unwrapRead(res, args);
190
+ const run = enqueueHost(() => wave.length === 1
191
+ ? rpc("call", ["read", wave[0].args]).then(leanEnvelope).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
192
+ : rpc("call", ["read", args]).then(leanEnvelope).then(res => { unwrapRead(res, args);
167
193
 
168
- return { values: res.items, errors: res.itemErrors ?? [] }; });
194
+ return { values: res.items, errors: res.itemErrors ?? [] }; }));
169
195
 
170
- void run.then(({ values, errors }) => {
196
+ const delivery = run.then(({ values, errors }) => {
171
197
  if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
172
198
 
173
199
  for (let i = 0; i < wave.length; i++) {
@@ -177,28 +203,80 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
177
203
  else wave[i].resolve(value);
178
204
  }
179
205
  }).catch(error => { for (const job of wave) job.reject(error); });
206
+
207
+ pendingReadWaves.add(delivery);
208
+ void delivery.finally(() => pendingReadWaves.delete(delivery));
180
209
  }
181
210
  }
182
211
 
183
- const invoke = (name, args) => { assertScope(); flushReads();
212
+ async function drainReads() {
213
+ for (;;) {
214
+ flushReads();
215
+ const waves = [...pendingReadWaves];
184
216
 
185
- return nova.call(name, args); };
217
+ if (!waves.length) return;
218
+ await Promise.allSettled(waves);
219
+ }
220
+ }
221
+
222
+ const invoke = (name, args) => {
223
+ assertScope(); flushReads();
224
+ const promise = nova.call(name, args);
186
225
 
187
- const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
188
- ? { ...p, path: p.path ?? p.query }
189
- : isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
226
+ promise.catch(() => {});
227
+
228
+ return promise;
229
+ };
230
+
231
+ const readArgs = (p, a, b) => {
232
+ if (isObject(p) && !Array.isArray(p)) {
233
+ const args = { ...p, path: p.path ?? p.target ?? p.query };
234
+
235
+ if (p.path === undefined && p.target !== undefined) delete args.target;
236
+
237
+ return args;
238
+ }
239
+
240
+ return isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
241
+ };
190
242
 
191
243
  const read = async (p, a, b) => {
192
244
  assertScope();
193
245
  const args = sessionJsonArgs(readArgs(p, a, b));
246
+ if (isString(args.path) && args.resolve === undefined && args.complete !== true && args.json === undefined && args.about === undefined && args.query === undefined && !looksLikePath(args.path) && !/^(?:agent|artifact):\/\//i.test(args.path)) {
247
+ args.resolve = true;
248
+ }
194
249
  validateJsonRead(args);
195
- const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
196
250
 
197
- if (args.complete === true && (args.outline || args.evidence || args.about)) throw new Error("complete:true requires a raw file read, not an outline or evidence view");
198
- const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
251
+ for (const [key, value] of [["resolve", args.resolve], ["complete", args.complete], ["outline", args.outline], ["evidence", args.evidence]]) {
252
+ if (value !== undefined && typeof value !== "boolean") throw new Error("read " + key + " must be a boolean");
253
+ }
254
+
255
+ if (args.about !== undefined && !isString(args.about)) throw new Error("read about must be a string");
256
+ if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
257
+ const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
258
+
259
+ if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
260
+ if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
261
+ const decode = value => {
262
+ if ((!args.resolve && args.json === undefined) || !isString(value)) return value;
263
+
264
+ try {
265
+ return JSON.parse(value);
266
+ } catch (error) {
267
+ const selector = args.json === undefined ? "" : " (" + (Array.isArray(args.json) ? args.json.join(", ") : String(args.json)) + ")";
268
+ throw new Error("JSON read failed for " + String(args.path ?? args.target ?? "resource") + selector + ": " + (error instanceof Error ? error.message : String(error)));
269
+ }
270
+ };
271
+
272
+ if (args.complete === true && (args.outline || args.evidence || args.about || args.query)) throw new Error("complete:true requires a raw file read, not a source view");
273
+ const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : (isString(p) && looksLikePath(p) ? p : undefined);
199
274
  p = args.path;
200
275
 
201
- if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
276
+ if (args.evidence) {
277
+ const query = args.about ?? args.query ?? (isString(p) ? p : undefined);
278
+ return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query }));
279
+ }
202
280
 
203
281
  if (args.outline) return unwrapJsonValue(await invoke("surface", args));
204
282
 
@@ -206,9 +284,24 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
206
284
  if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
207
285
 
208
286
  for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
209
- const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
287
+ const readEach = async () => {
288
+ const values = await Promise.all(p.map(item => read({ ...args, path: item })));
289
+ const missing = args.resolve ? values.findIndex((value, index) => value?.status === "not_found" && looksLikePath(p[index])) : -1;
290
+
291
+ if (missing >= 0) throw new Error(`read failed for ${p[missing]}: not_found; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes`);
292
+
293
+ return values;
294
+ };
295
+
296
+ const guardedReadEach = () => {
297
+ const promise = readEach();
298
+
299
+ promise.catch(() => {});
300
+
301
+ return promise;
302
+ };
210
303
 
211
- if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
304
+ if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return guardedReadEach();
212
305
  const res = await invoke("read", args);
213
306
  const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
214
307
 
@@ -218,7 +311,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
218
311
  if (Array.isArray(res?.items)) return res.items.map(decode);
219
312
 
220
313
  // Captured host executor without batch support: fan out.
221
- return readEach();
314
+ return guardedReadEach();
222
315
  }
223
316
 
224
317
  if (!batchRead) {
@@ -232,19 +325,44 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
232
325
 
233
326
  if (queuedReads.length && queuedReads[0].key !== key) flushReads();
234
327
 
235
- return new Promise((resolve, reject) => {
328
+ const promise = new Promise((resolve, reject) => {
236
329
  queuedReads.push({ args, key, resolve, reject });
237
330
 
238
331
  if (queuedReads.length === 1) queueMicrotask(flushReads);
239
332
  }).then(decode);
333
+
334
+ promise.catch(() => {});
335
+
336
+ return promise;
240
337
  };
241
338
 
242
339
  const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
243
340
 
341
+ const viewSpan = (value) => {
342
+ const start = isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
343
+ const end = isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
344
+
345
+ if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
346
+
347
+ return { start: Math.floor(start), end: Math.floor(end) };
348
+ };
349
+
350
+ const isView = (value) => isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.trim() && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
351
+
244
352
  const edit = async (p, oldText, newText) => {
245
353
  if (isFunction(p)) return nova.speculate(p);
246
354
  const usage = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
247
355
 
356
+ if (isView(p) && isString(oldText) && (newText === undefined || isString(newText))) {
357
+ if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
358
+ const span = viewSpan(p);
359
+ const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
360
+
361
+ if (newText !== undefined) args.oldText = oldText;
362
+
363
+ return unwrapValue(await invoke("edit", args));
364
+ }
365
+
248
366
  if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
249
367
 
250
368
  if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
@@ -268,19 +386,22 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
268
386
  return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
269
387
  };
270
388
 
271
- const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
272
-
273
389
  const bash = async (command, opts) => {
274
390
  const args = isObject(command) ? { ...command } : { command, ...opts };
275
391
 
276
392
  if (args.args !== undefined) {
277
- if (!isString(args.command) || !Array.isArray(args.args) || args.args.some(arg => !isString(arg))) throw new Error("bash argv requires a command string and an array of string args");
393
+ if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
394
+
395
+ for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error("bash argv requires a command string and an array of string args");
396
+ args.args = args.args.map(String);
278
397
 
279
- if (nativeArgv) args._directArgv = true;
280
- else {
398
+ // Literal argv is a Supernova-owned contract. Host bash tools often ignore
399
+ // `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
400
+ if (process.platform === "win32") {
281
401
  delete args._directArgv;
282
402
  args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
283
- }
403
+ delete args.args;
404
+ } else args._directArgv = true;
284
405
  }
285
406
 
286
407
  command = args.command;
@@ -307,20 +428,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
307
428
  return text;
308
429
  };
309
430
 
310
- const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
311
-
312
- const exec = async (cmd, args, opts) => {
313
- const command = String(cmd ?? "").trim();
314
-
315
- if (!command) throw new Error("exec requires command");
316
-
317
- // exec("git status") is a shell line; exec("git", ["status"]) is argv.
318
- if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
319
-
320
- return bash([command, ...args].map(quoteShellArg).join(" "), opts);
321
- };
322
-
323
- return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, evidence: nova.evidence, bash, exec, speculate: nova.speculate };
431
+ return { read, write, edit, bash, nova };
324
432
  }
325
433
 
326
434
  function makeConsole(runId, limits) {
@@ -344,7 +452,10 @@ function makeConsole(runId, limits) {
344
452
  if (isString(a)) return a;
345
453
 
346
454
  try {
347
- return JSON.stringify(toPlain(a));
455
+ const plain = toPlain(a);
456
+ const encoded = JSON.stringify(plain);
457
+
458
+ return encoded === undefined ? String(plain) : encoded;
348
459
  } catch {
349
460
  return String(a);
350
461
  }
@@ -1,10 +1,13 @@
1
- import { isFunction } from "../shared/decode.js";
1
+ import { isFunction, isObject } 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
5
  const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
6
6
 
7
7
  export function isMutatingTool(name, config = {}, args = {}, definition) {
8
+ if (!isObject(args)) args = {};
9
+ name = String(name);
10
+
8
11
  if ((config.mutatingTools ?? []).includes(name)) return true;
9
12
 
10
13
  if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
@@ -1,7 +1,7 @@
1
1
  import { isObject, isString } from "../shared/decode.js";
2
2
  import { truncateChars } from "../output/format.js";
3
3
 
4
- const textOf = result => result.content.filter(block => block.type === "text").map(block => block.text).join("\n");
4
+ const textOf = result => (Array.isArray(result?.content) ? result.content : []).filter(block => block?.type === "text").map(block => block.text).join("\n");
5
5
 
6
6
  const mutationTotals = results => results.reduce((total, result) => {
7
7
  const m = result.details?.mutations;
@@ -24,7 +24,7 @@ export function programBatchText(results, total, stopped = "") {
24
24
  }
25
25
 
26
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");
27
+ if (["code","file"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code or file; no programs ran");
28
28
 
29
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
30
 
@@ -35,20 +35,31 @@ function batchInputs(params, config) {
35
35
  }
36
36
  }
37
37
 
38
+ const hasDefault = params.data !== undefined;
38
39
  let encoded;
39
40
 
40
- try { encoded = JSON.stringify(params.programs); } catch { throw new Error("programs must be JSON-serializable; no programs ran"); }
41
+ try { encoded = JSON.stringify(hasDefault ? {programs:params.programs,data:params.data} : params.programs); } catch { throw new Error("programs and data must be JSON-serializable; no programs ran"); }
41
42
 
42
43
  if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
43
44
 
44
- return JSON.parse(encoded);
45
+ const parsed = JSON.parse(encoded);
46
+
47
+ if (!hasDefault) return parsed;
48
+ if (!Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
49
+
50
+ // The runtime snapshots data separately for each fresh guest. An explicit
51
+ // entry replaces the default wholesale; falsy values are not missing values.
52
+ return parsed.programs.map(program => program.data === undefined ? {...program,data:parsed.data} : program);
45
53
  }
46
54
 
47
55
  /** Explicit known continuations, not inferred plans, retries, or a shared heap. */
48
56
  export async function runProgramBatch(id, params, signal, onUpdate, ctx, config, execute) {
49
57
  const programs = batchInputs(params,config);
58
+ const requestedTimeout = params.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
59
+
60
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("program batch timeoutMs must be a positive finite number");
50
61
  const started = performance.now();
51
- const timeout = Number.isInteger(params.timeoutMs) ? params.timeoutMs : config.timeoutMs;
62
+ const timeout = requestedTimeout;
52
63
  const deadline = started + timeout;
53
64
  const controller = new AbortController();
54
65
  const combined = signal ? AbortSignal.any([signal,controller.signal]) : controller.signal;
@@ -76,7 +87,7 @@ export async function runProgramBatch(id, params, signal, onUpdate, ctx, config,
76
87
  trace.push(...(result.details?.trace ?? []));
77
88
  let image = 0;
78
89
 
79
- for (const block of result.content) if (block.type === "image") {
90
+ for (const block of Array.isArray(result?.content) ? result.content : []) if (block?.type === "image" && isString(block.data)) {
80
91
  imageBytes += Buffer.byteLength(block.data,"base64");
81
92
 
82
93
  if (images.length >= 16 || imageBytes > 20*1024*1024) { stopped = "batch image budget exceeded; remaining programs did not run"; break; }
@@ -4,6 +4,9 @@ import { resolveWorkspacePath } from "../fs/workspace.js";
4
4
  /** Explicit source reuse, never a cached program or a persistent guest heap. */
5
5
  export async function readProgramFile(file, cwd, maxChars, signal) {
6
6
  signal?.throwIfAborted();
7
+ const chars = Number(maxChars);
8
+
9
+ if (!Number.isInteger(chars) || chars <= 0) throw new Error("program file maxChars must be a positive integer");
7
10
  const target = await resolveWorkspacePath(cwd, file, "program file", false, true);
8
11
  // A FIFO must fail without waiting for a writer or occupying an I/O worker.
9
12
  const handle = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
@@ -15,8 +18,8 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
15
18
  if (!stat.isFile()) throw new Error("program file requires a regular file");
16
19
  // Every UTF-16 unit needs at most three UTF-8 bytes. Also check streamed size:
17
20
  // 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");
21
+ const maxBytes = chars * 3;
22
+ const tooLarge = () => new Error("code exceeds " + chars + " characters; split the program");
20
23
 
21
24
  if (stat.size > maxBytes) throw tooLarge();
22
25
  const chunks = [];
@@ -33,7 +36,7 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
33
36
  // Do not silently replace invalid bytes in executable source. Preserve BOMs.
34
37
  const code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
35
38
 
36
- if (code.length > maxChars) throw tooLarge();
39
+ if (code.length > chars) throw tooLarge();
37
40
 
38
41
  return code;
39
42
  } finally { await handle.close(); }
@@ -1,25 +1,18 @@
1
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.
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
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
4
+ read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries[]; up to 64 paths
5
+ read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP); 20 MiB max, 16 attachments max
6
+ read({path,json:".field"}) → parsed JSON; .items[0:3], quoted keys, 1-64 selectors, 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; source questions use at most 16 keywords
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
18
16
 
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.`;
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; top-level data defaults per entry, explicit entry data replaces it. Stop on failure keeps earlier commits. Separate calls when the next step needs a model decision.`;
@@ -47,7 +47,7 @@ function prepareProgram(code) {
47
47
 
48
48
  if (candidate && FUNCTION_TYPES.has(candidate.type)) {
49
49
  expression = candidate;
50
- expressionSource = code.slice(0, statement.end).replace(/;\s*$/, "");
50
+ expressionSource = code.slice(statement.start, statement.end).replace(/;\s*$/, "");
51
51
  }
52
52
  } catch (bodyError) {
53
53
  expressionSource = code.trimEnd().replace(/;+\s*$/, "");
@@ -154,8 +154,6 @@ const RPC_METHODS = {
154
154
 
155
155
  return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
156
156
  },
157
- search: (nova, args) => nova.search(args[0], args[1]),
158
- describe: (nova, args) => nova.describe(args[0]),
159
157
  speculateBegin: (nova) => nova.speculateBegin(),
160
158
  speculateCommit: (nova) => nova.speculateCommit(),
161
159
  speculateRollback: (nova) => nova.speculateRollback(),
@@ -187,7 +185,10 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
187
185
 
188
186
  if (signal?.aborted) return fail(ABORT_MESSAGE);
189
187
  const runId = ++runSeq;
190
- const timeoutMs = config.timeoutMs ?? 60000;
188
+ const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
189
+
190
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return fail("timeoutMs must be a positive finite number");
191
+ const timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
191
192
  const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
192
193
 
193
194
  return new Promise((resolve) => {
@@ -197,6 +198,7 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
197
198
  let completing = false;
198
199
  let hostError;
199
200
  let notifyingHost = false;
201
+ let aborting = false;
200
202
  const pending = new Set();
201
203
  const inputController = new AbortController();
202
204
 
@@ -226,11 +228,13 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
226
228
  };
227
229
 
228
230
  const abort = () => {
229
- if (finished) return;
231
+ if (finished || aborting) return;
232
+ aborting = true;
230
233
  cancelHost();
231
234
 
232
235
  try { onTimeout?.(); } catch {}
233
236
 
237
+ aborting = false;
234
238
  finish(fail(ABORT_MESSAGE));
235
239
  };
236
240
 
@@ -269,8 +273,11 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
269
273
  handle?.worker.off("exit", onExit);
270
274
  void killWorker(handle);
271
275
 
272
- if (!outcome.ok) cancelHost();
273
- await Promise.allSettled(pending);
276
+ if (pending.size || !outcome.ok) cancelHost();
277
+ // A host tool that ignores cancellation must not keep the result unsettled
278
+ // after the guest worker is gone. Give it a brief drain window only.
279
+ await Promise.race([Promise.allSettled(pending), new Promise(resolve => setTimeout(resolve, 250))]);
280
+ if (pending.size && outcome.ok) hostError ??= "program completed with a host call still running";
274
281
 
275
282
  if (finished) return;
276
283
  finish(outcome.ok && hostError ? fail(hostError) : outcome);
@@ -341,7 +348,9 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
341
348
  await handle.ready;
342
349
 
343
350
  if (finished || signal?.aborted) return abort();
344
- const available = isFunction(nova.names) ? await nova.names() : [];
351
+ let available = isFunction(nova.names) ? await nova.names() : [];
352
+
353
+ if (!Array.isArray(available)) available = [];
345
354
 
346
355
  if (finished || signal?.aborted) return abort();
347
356
  handle.worker.on("message", onMessage);