pi-supernova 0.5.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.
@@ -16,6 +16,9 @@ const realNearest = new Map();
16
16
  const PATH_CACHE_MAX = 2048;
17
17
 
18
18
  export function clearPathCache() {
19
+ cachedCwd = null;
20
+ cachedResolvedCwd = null;
21
+ realRoots.clear();
19
22
  realNearest.clear();
20
23
  }
21
24
 
@@ -126,7 +129,10 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
126
129
  export async function runCommand(argv, options = {}) {
127
130
  options.signal?.throwIfAborted();
128
131
  const cwd = options.cwd || process.cwd();
129
- const timeoutMs = options.timeoutMs ?? 60_000;
132
+ const requestedTimeout = Number(options.timeoutMs === undefined ? 60_000 : options.timeoutMs);
133
+
134
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
135
+ const timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
130
136
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
131
137
 
132
138
  return new Promise((resolve, reject) => {
@@ -19,7 +19,7 @@ function detailsOf(raw) {
19
19
  export function hostResultFailed(raw) {
20
20
  const details = detailsOf(raw);
21
21
 
22
- return raw?.isError === true || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
22
+ return raw?.isError === true || raw?.ok === false || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
23
23
  }
24
24
 
25
25
  function extractRawString(raw) {
@@ -92,12 +92,16 @@ function summarizeDetails(value, budget = 2000) {
92
92
  }
93
93
 
94
94
  function spill(fullText, config) {
95
- if (!isString(config.spillDir) || !config.spillDir) return undefined;
96
- fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
97
- const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
98
- fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
99
-
100
- return file;
95
+ try {
96
+ if (!isString(config.spillDir) || !config.spillDir) return undefined;
97
+ fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
98
+ const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
99
+ fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
100
+
101
+ return file;
102
+ } catch {
103
+ return undefined;
104
+ }
101
105
  }
102
106
 
103
107
  export function packageHostResult(raw, config) {
@@ -108,7 +112,10 @@ export function packageHostResult(raw, config) {
108
112
  const capped = truncateChars(text, maxChars, "host-result");
109
113
  let truncated = capped.truncated || details?.outputTruncated === true;
110
114
  const image = raw?.content?.find(part => part?.type === "image");
111
- const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
115
+ const directoryEntries = image === undefined && details?.directory === true && Array.isArray(details.entries) && json(details.entries).length <= maxChars
116
+ ? details.entries
117
+ : undefined;
118
+ const result = { ok: !hostResultFailed(raw), value: image ?? directoryEntries ?? capped.text, truncated };
112
119
 
113
120
  if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
114
121
 
@@ -117,7 +124,25 @@ export function packageHostResult(raw, config) {
117
124
  let remaining = maxChars;
118
125
  result.items = batch.map((item, index) => {
119
126
  if (item?.type === "image") return item;
120
- const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
127
+ const share = details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index));
128
+
129
+ if (!isString(item)) {
130
+ const encoded = json(item);
131
+
132
+ if (encoded.length <= share) {
133
+ remaining -= encoded.length;
134
+
135
+ return item;
136
+ }
137
+
138
+ const bounded = truncateChars(encoded, share, "host-result");
139
+ remaining -= bounded.text.length;
140
+ truncated ||= bounded.truncated;
141
+
142
+ return bounded.text;
143
+ }
144
+
145
+ const bounded = truncateChars(item, share, "host-result");
121
146
  remaining -= bounded.text.length;
122
147
  truncated ||= bounded.truncated;
123
148
 
@@ -128,10 +153,10 @@ export function packageHostResult(raw, config) {
128
153
  result.truncated = truncated;
129
154
 
130
155
  if (truncated) {
131
- result.originalChars = batch ? batch.reduce((sum, item) => sum + String(item).length, 0) : text.length;
156
+ result.originalChars = batch ? batch.reduce((sum, item) => sum + (isString(item) ? item.length : json(item).length), 0) : text.length;
132
157
 
133
158
  if (config.spillDir) {
134
- const pointer = spill(batch ? batch.join("\n---\n") : text, config);
159
+ const pointer = spill(batch ? batch.map(item => isString(item) ? item : json(item)).join("\n---\n") : text, config);
135
160
 
136
161
  if (pointer) {
137
162
  result.spill = pointer;
@@ -143,7 +143,8 @@ function formatPrimitive(value) {
143
143
 
144
144
  if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
145
145
 
146
- return JSON.stringify(value) ?? String(value);
146
+ try { return JSON.stringify(value) ?? String(value); }
147
+ catch { return String(value); }
147
148
  }
148
149
 
149
150
  /**
@@ -168,39 +169,46 @@ function formatFlatWithin(value, limit) {
168
169
  return true;
169
170
  };
170
171
 
171
- return walkFlat(value, push) ? parts.join("") : null;
172
+ return walkFlat(value, push, new Set()) ? parts.join("") : null;
172
173
  }
173
174
 
174
- function walkFlat(value, push) {
175
+ function walkFlat(value, push, seen) {
175
176
  if (value?.[RAW_TEXT] !== undefined) return push(value[RAW_TEXT]);
176
177
 
177
178
  if (!isObject(value) && !Array.isArray(value)) return push(formatPrimitive(value));
178
179
 
179
- if (Array.isArray(value)) {
180
- if (value.length === 0) return push("[]");
180
+ if (seen.has(value)) return push("[Circular]");
181
+ seen.add(value);
181
182
 
182
- if (!push("[")) return false;
183
+ try {
184
+ if (Array.isArray(value)) {
185
+ if (value.length === 0) return push("[]");
183
186
 
184
- for (let i = 0; i < value.length; i++) {
185
- if (i && !push(",")) return false;
187
+ if (!push("[")) return false;
186
188
 
187
- if (!walkFlat(value[i] === undefined ? null : value[i], push)) return false;
189
+ for (let i = 0; i < value.length; i++) {
190
+ if (i && !push(",")) return false;
191
+
192
+ if (!walkFlat(value[i] === undefined ? null : value[i], push, seen)) return false;
193
+ }
194
+
195
+ return push("]");
188
196
  }
189
197
 
190
- return push("]");
191
- }
198
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
192
199
 
193
- const keys = Object.keys(value).filter((key) => value[key] !== undefined);
200
+ if (keys.length === 0) return push("{}");
194
201
 
195
- if (keys.length === 0) return push("{}");
202
+ for (let i = 0; i < keys.length; i++) {
203
+ if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
196
204
 
197
- for (let i = 0; i < keys.length; i++) {
198
- if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
205
+ if (!walkFlat(value[keys[i]], push, seen)) return false;
206
+ }
199
207
 
200
- if (!walkFlat(value[keys[i]], push)) return false;
208
+ return push("}");
209
+ } finally {
210
+ seen.delete(value);
201
211
  }
202
-
203
- return push("}");
204
212
  }
205
213
 
206
214
  /**
@@ -209,24 +217,31 @@ function walkFlat(value, push) {
209
217
  * indent is one space. Whitespace is what costs tokens: this measures ~43% fewer
210
218
  * than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
211
219
  */
212
- export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
220
+ export function formatValue(value, indent = "", width = FORMAT_WIDTH, seen = new Set()) {
213
221
  if (value?.[RAW_TEXT] !== undefined) return value[RAW_TEXT];
214
222
 
215
223
  if (!isObject(value) && !Array.isArray(value)) return formatPrimitive(value);
216
- const flat = formatFlatWithin(value, width - indent.length);
224
+ if (seen.has(value)) return "[Circular]";
225
+ seen.add(value);
217
226
 
218
- if (flat !== null) return flat;
219
- const pad = indent + " ";
227
+ try {
228
+ const flat = formatFlatWithin(value, width - indent.length);
220
229
 
221
- if (Array.isArray(value)) {
222
- if (value.length === 0) return "[]";
230
+ if (flat !== null) return flat;
231
+ const pad = indent + " ";
223
232
 
224
- return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
225
- }
233
+ if (Array.isArray(value)) {
234
+ if (value.length === 0) return "[]";
235
+
236
+ return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width, seen)).join(",\n") + "\n" + indent + "]";
237
+ }
226
238
 
227
- const keys = Object.keys(value).filter((key) => value[key] !== undefined);
239
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
228
240
 
229
- if (keys.length === 0) return "{}";
241
+ if (keys.length === 0) return "{}";
230
242
 
231
- return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
243
+ return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width, seen)).join(",\n") + "\n" + indent + "}";
244
+ } finally {
245
+ seen.delete(value);
246
+ }
232
247
  }
@@ -110,18 +110,41 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
110
110
  if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
111
111
  };
112
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
+
113
122
  const nova = {
114
- call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
115
- async callMany(calls) {
116
- const wave = await rpc("callMany", [calls]);
117
- const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
118
- Object.defineProperties(results, {
119
- mode: { value: wave?.mode, enumerable: false },
120
- reason: { value: wave?.reason, enumerable: false },
121
- 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;
122
143
  });
123
144
 
124
- return results;
145
+ promise.catch(() => {});
146
+
147
+ return promise;
125
148
  },
126
149
  async speculate(fn) {
127
150
  if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
@@ -130,30 +153,31 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
130
153
  let began = false;
131
154
 
132
155
  try {
133
- flushReads();
134
- await rpc("speculateBegin", []);
156
+ await drainReads();
157
+ await enqueueHost(() => rpc("speculateBegin", []));
135
158
  began = true;
136
159
  const value = await checkpointScope.run(token, fn);
137
- flushReads();
138
- await rpc("speculateCommit", []);
160
+ await drainReads();
161
+ await enqueueHost(() => rpc("speculateCommit", []));
139
162
 
140
163
  return { ok: true, committed: true, value };
141
164
  } catch (err) {
142
- flushReads();
165
+ await drainReads();
143
166
 
144
- if (began) await rpc("speculateRollback", []);
167
+ if (began) await enqueueHost(() => rpc("speculateRollback", []));
145
168
 
146
169
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
147
170
  } finally { checkpoint = null; }
148
171
  },
149
- surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
150
- evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
151
- 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; },
152
175
  has: (name) => availableSet.has(name),
153
176
  };
154
177
 
155
178
  // Coalesce already-started compatible reads without rewriting JS control flow.
156
179
  let queuedReads = [];
180
+ const pendingReadWaves = new Set();
157
181
 
158
182
  function flushReads() {
159
183
  const pending = queuedReads;
@@ -163,13 +187,13 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
163
187
  const wave = pending.slice(start, start + 64);
164
188
  const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
165
189
 
166
- const run = wave.length === 1
167
- ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
168
- : 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);
169
193
 
170
- return { values: res.items, errors: res.itemErrors ?? [] }; });
194
+ return { values: res.items, errors: res.itemErrors ?? [] }; }));
171
195
 
172
- void run.then(({ values, errors }) => {
196
+ const delivery = run.then(({ values, errors }) => {
173
197
  if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
174
198
 
175
199
  for (let i = 0; i < wave.length; i++) {
@@ -179,31 +203,80 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
179
203
  else wave[i].resolve(value);
180
204
  }
181
205
  }).catch(error => { for (const job of wave) job.reject(error); });
206
+
207
+ pendingReadWaves.add(delivery);
208
+ void delivery.finally(() => pendingReadWaves.delete(delivery));
182
209
  }
183
210
  }
184
211
 
185
- const invoke = (name, args) => { assertScope(); flushReads();
212
+ async function drainReads() {
213
+ for (;;) {
214
+ flushReads();
215
+ const waves = [...pendingReadWaves];
216
+
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);
225
+
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 };
186
234
 
187
- return nova.call(name, args); };
235
+ if (p.path === undefined && p.target !== undefined) delete args.target;
188
236
 
189
- const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
190
- ? { ...p, path: p.path ?? p.query }
191
- : isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
237
+ return args;
238
+ }
239
+
240
+ return isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
241
+ };
192
242
 
193
243
  const read = async (p, a, b) => {
194
244
  assertScope();
195
245
  const args = sessionJsonArgs(readArgs(p, a, b));
196
- if (isString(args.path) && args.resolve === undefined && args.json === undefined && !looksLikePath(args.path) && !/^(?:agent|artifact):\/\//i.test(args.path)) {
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)) {
197
247
  args.resolve = true;
198
248
  }
199
249
  validateJsonRead(args);
200
- const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
201
250
 
202
- 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");
203
- 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);
204
274
  p = args.path;
205
275
 
206
- 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
+ }
207
280
 
208
281
  if (args.outline) return unwrapJsonValue(await invoke("surface", args));
209
282
 
@@ -211,9 +284,24 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
211
284
  if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
212
285
 
213
286
  for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
214
- 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;
215
290
 
216
- if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
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
+ };
303
+
304
+ if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return guardedReadEach();
217
305
  const res = await invoke("read", args);
218
306
  const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
219
307
 
@@ -223,7 +311,7 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
223
311
  if (Array.isArray(res?.items)) return res.items.map(decode);
224
312
 
225
313
  // Captured host executor without batch support: fan out.
226
- return readEach();
314
+ return guardedReadEach();
227
315
  }
228
316
 
229
317
  if (!batchRead) {
@@ -237,11 +325,15 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
237
325
 
238
326
  if (queuedReads.length && queuedReads[0].key !== key) flushReads();
239
327
 
240
- return new Promise((resolve, reject) => {
328
+ const promise = new Promise((resolve, reject) => {
241
329
  queuedReads.push({ args, key, resolve, reject });
242
330
 
243
331
  if (queuedReads.length === 1) queueMicrotask(flushReads);
244
332
  }).then(decode);
333
+
334
+ promise.catch(() => {});
335
+
336
+ return promise;
245
337
  };
246
338
 
247
339
  const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
@@ -301,6 +393,7 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
301
393
  if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
302
394
 
303
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);
304
397
 
305
398
  // Literal argv is a Supernova-owned contract. Host bash tools often ignore
306
399
  // `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
@@ -335,7 +428,7 @@ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
335
428
  return text;
336
429
  };
337
430
 
338
- return { read, write, edit, bash };
431
+ return { read, write, edit, bash, nova };
339
432
  }
340
433
 
341
434
  function makeConsole(runId, limits) {
@@ -359,7 +452,10 @@ function makeConsole(runId, limits) {
359
452
  if (isString(a)) return a;
360
453
 
361
454
  try {
362
- return JSON.stringify(toPlain(a));
455
+ const plain = toPlain(a);
456
+ const encoded = JSON.stringify(plain);
457
+
458
+ return encoded === undefined ? String(plain) : encoded;
363
459
  } catch {
364
460
  return String(a);
365
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,11 +1,11 @@
1
1
  // Complete model-facing API reference; kept in every request, not moved into history.
2
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
- 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
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
7
  read("agent://id?q=.answer") → JSON field from session artifacts
8
- read("symbol or question") → same view as resolve:true
8
+ read("symbol or question") → same view as resolve:true; source questions use at most 16 keywords
9
9
  read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
10
10
  read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
11
11
  write(path, text) → replace; write({path,content,append:true}) → append without a prior read
@@ -15,4 +15,4 @@ bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
15
15
  bash({command,args}) → literal argv, no shell expansion of args
16
16
 
17
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.`;
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.`;