pi-supernova 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,9 @@ export function truncateChars(text, maxChars, label = "value") {
4
4
  const normalized = isString(text) ? text : String(text ?? "");
5
5
  const numericLimit = Number(maxChars);
6
6
  const limit = Number.isFinite(numericLimit) ? Math.max(0, Math.floor(numericLimit)) : numericLimit === Infinity ? normalized.length : 0;
7
+
7
8
  if (normalized.length <= limit) return { text: normalized, truncated: false };
9
+
8
10
  if (limit <= 100) {
9
11
  return {
10
12
  text: normalized.slice(0, headEnd(normalized, limit)),
@@ -12,20 +14,25 @@ export function truncateChars(text, maxChars, label = "value") {
12
14
  originalChars: normalized.length,
13
15
  };
14
16
  }
17
+
15
18
  let head = headEnd(normalized, Math.floor(limit * 0.7));
16
19
  let tail = 0;
17
20
  let marker = "";
21
+
18
22
  for (;;) {
19
23
  const omitted = normalized.length - head - tail;
20
24
  marker = "\n…[" + label + " truncated " + omitted + " chars]…\n";
25
+
21
26
  if (marker.length > limit) return { text: normalized.slice(0, headEnd(normalized, limit)), truncated: true, originalChars: normalized.length };
22
27
  const budget = limit - marker.length;
23
28
  const nextHead = headEnd(normalized, Math.min(head, budget));
24
29
  const nextTail = normalized.length - tailStartIndex(normalized, Math.max(0, budget - nextHead));
30
+
25
31
  if (nextHead === head && nextTail === tail) break;
26
32
  head = nextHead;
27
33
  tail = nextTail;
28
34
  }
35
+
29
36
  return { text: normalized.slice(0, head) + marker + normalized.slice(normalized.length - tail), truncated: true, originalChars: normalized.length };
30
37
  }
31
38
 
@@ -33,6 +40,7 @@ export function truncateChars(text, maxChars, label = "value") {
33
40
  // boundary, so a cut must never split a surrogate pair.
34
41
  function headEnd(text, end) {
35
42
  const code = text.charCodeAt(end - 1);
43
+
36
44
  return code >= 0xd800 && code <= 0xdbff ? end - 1 : end;
37
45
  }
38
46
 
@@ -40,29 +48,90 @@ function tailStartIndex(text, tail) {
40
48
  if (tail <= 0) return text.length;
41
49
  const start = text.length - tail;
42
50
  const code = text.charCodeAt(start);
51
+
43
52
  return code >= 0xdc00 && code <= 0xdfff ? start + 1 : start;
44
53
  }
45
54
 
46
55
  // Unicode mode matches lone surrogate code points, not valid UTF-16 pairs.
47
56
  const UNPAIRED_SURROGATE = /[\uD800-\uDFFF]/u;
48
57
 
58
+ // Without the Unicode flag this class matches any surrogate code unit, including
59
+ // the halves of a valid pair, so it is a strictly wider test. Source text almost
60
+ // never contains a surrogate at all, and the wide scan decides those strings on
61
+ // its own; only text that really has one pays for the precise Unicode pass.
62
+ const SURROGATE_CODE_UNIT = /[\uD800-\uDFFF]/;
63
+
64
+ function hasUnpairedSurrogate(text) {
65
+ return SURROGATE_CODE_UNIT.test(text) && UNPAIRED_SURROGATE.test(text);
66
+ }
67
+
49
68
  function hasWellFormedStrings(values) {
50
- for (const value of values) if (!isString(value) || UNPAIRED_SURROGATE.test(value)) return false;
69
+ for (const value of values) if (!isString(value) || hasUnpairedSurrogate(value)) return false;
70
+
51
71
  return true;
52
72
  }
53
73
 
74
+ /** Keep every array item in an oversized return by giving each a fair truncated share. */
75
+ export function formatBoundedStringArray(values, budget) {
76
+ const n = values.length;
77
+ const header = "strings[" + n + "]\n";
78
+ let remaining = Math.max(0, budget - header.length);
79
+ let out = header;
80
+
81
+ for (let i = 0; i < n; i++) {
82
+ const itemHeader = "[" + i + "] " + values[i].length + " UTF-16 units\n";
83
+ const per = Math.max(32, Math.floor(remaining / (n - i)) - itemHeader.length - 1);
84
+ const bounded = truncateChars(values[i], per, "return");
85
+ const chunk = itemHeader + bounded.text + "\n";
86
+ remaining = Math.max(0, remaining - chunk.length);
87
+ out += chunk;
88
+ }
89
+
90
+ return out;
91
+ }
92
+
54
93
  /** Lossless framing for source arrays, not string escaping or source compression. */
55
94
  export function formatReturn(value) {
56
95
  if (isString(value)) return value;
96
+
57
97
  if (Array.isArray(value) && value.length && hasWellFormedStrings(value) && value.some(text => text.includes("\n"))) {
58
98
  const raw = "strings[" + value.length + "]\n" + value.map((text, i) => "[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
59
99
  const escapedSize = value.reduce((sum, text) => sum + JSON.stringify(text).length, value.length + 1);
100
+
60
101
  if (raw.length < escapedSize) return raw;
61
102
  }
62
- return formatValue(value);
103
+
104
+ const escaped = formatValue(value);
105
+ const strings = [];
106
+
107
+ const visit = input => {
108
+ if (isString(input) && input.includes("\n") && !hasUnpairedSurrogate(input) && JSON.stringify(input).length - input.length > 64) {
109
+ const index = strings.push(input) - 1;
110
+
111
+ return { [RAW_TEXT]: "raw[" + index + "]" };
112
+ }
113
+
114
+ if (Array.isArray(input)) return input.map(visit);
115
+
116
+ if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visit(child)]));
117
+
118
+ return input;
119
+ };
120
+
121
+ const referencedValue = visit(value);
122
+
123
+ if (!strings.length) return escaped;
124
+ // Keep every key, value, duplicate string and byte. References are unquoted
125
+ // expressions, so literal "raw[0]" values and header-like source cannot collide.
126
+ const framed = formatValue(referencedValue) + "\nraw strings[" + strings.length + "]\n" + strings.map((text, i) => "raw[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
127
+
128
+ return framed.length < escaped.length ? framed : escaped;
63
129
  }
64
130
 
131
+ const RAW_TEXT = Symbol("raw text reference");
132
+
65
133
  const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
134
+
66
135
  const FORMAT_WIDTH = 120;
67
136
 
68
137
  function formatKey(key) {
@@ -71,26 +140,67 @@ function formatKey(key) {
71
140
 
72
141
  function formatPrimitive(value) {
73
142
  if (value === undefined) return "undefined";
143
+
74
144
  if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
145
+
75
146
  return JSON.stringify(value) ?? String(value);
76
147
  }
77
148
 
78
- function formatFlatList(value) {
79
- if (value.length === 0) return "[]";
80
- let out = "[";
81
- for (let i = 0; i < value.length; i++) out += (i ? "," : "") + formatFlat(value[i] === undefined ? null : value[i]);
82
- return out + "]";
149
+ /**
150
+ * Single-line rendering that gives up the moment it would exceed `limit` characters.
151
+ *
152
+ * formatValue consults this at every level, so the previous build-the-whole-string
153
+ * form re-walked each subtree once per ancestor: a container that is too wide paid
154
+ * the full cost of its children and then paid for them again while descending.
155
+ * Abandoning at the budget keeps the pass linear. Returning null is exactly
156
+ * equivalent to "the flat form is longer than limit", because an abandoned walk
157
+ * always has at least `used` characters still to come.
158
+ */
159
+ function formatFlatWithin(value, limit) {
160
+ const parts = [];
161
+ let used = 0;
162
+
163
+ const push = (chunk) => {
164
+ if (used + chunk.length > limit) return false;
165
+ used += chunk.length;
166
+ parts.push(chunk);
167
+
168
+ return true;
169
+ };
170
+
171
+ return walkFlat(value, push) ? parts.join("") : null;
83
172
  }
84
173
 
85
- function formatFlat(value) {
86
- if ((!isObject(value) && !Array.isArray(value))) return formatPrimitive(value);
87
- if (Array.isArray(value)) return formatFlatList(value);
88
- let out = "";
89
- for (const key of Object.keys(value)) {
90
- if (value[key] === undefined) continue;
91
- out += (out ? "," : "{") + formatKey(key) + ":" + formatFlat(value[key]);
174
+ function walkFlat(value, push) {
175
+ if (value?.[RAW_TEXT] !== undefined) return push(value[RAW_TEXT]);
176
+
177
+ if (!isObject(value) && !Array.isArray(value)) return push(formatPrimitive(value));
178
+
179
+ if (Array.isArray(value)) {
180
+ if (value.length === 0) return push("[]");
181
+
182
+ if (!push("[")) return false;
183
+
184
+ for (let i = 0; i < value.length; i++) {
185
+ if (i && !push(",")) return false;
186
+
187
+ if (!walkFlat(value[i] === undefined ? null : value[i], push)) return false;
188
+ }
189
+
190
+ return push("]");
92
191
  }
93
- return out ? out + "}" : "{}";
192
+
193
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
194
+
195
+ if (keys.length === 0) return push("{}");
196
+
197
+ for (let i = 0; i < keys.length; i++) {
198
+ if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
199
+
200
+ if (!walkFlat(value[keys[i]], push)) return false;
201
+ }
202
+
203
+ return push("}");
94
204
  }
95
205
 
96
206
  /**
@@ -100,14 +210,23 @@ function formatFlat(value) {
100
210
  * than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
101
211
  */
102
212
  export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
103
- const flat = formatFlat(value);
104
- if ((!isObject(value) && !Array.isArray(value)) || flat.length + indent.length <= width) return flat;
213
+ if (value?.[RAW_TEXT] !== undefined) return value[RAW_TEXT];
214
+
215
+ if (!isObject(value) && !Array.isArray(value)) return formatPrimitive(value);
216
+ const flat = formatFlatWithin(value, width - indent.length);
217
+
218
+ if (flat !== null) return flat;
105
219
  const pad = indent + " ";
220
+
106
221
  if (Array.isArray(value)) {
107
222
  if (value.length === 0) return "[]";
223
+
108
224
  return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
109
225
  }
226
+
110
227
  const keys = Object.keys(value).filter((key) => value[key] !== undefined);
228
+
111
229
  if (keys.length === 0) return "{}";
230
+
112
231
  return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
113
232
  }
@@ -1,28 +1,38 @@
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
+ import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
5
6
 
6
7
  // Guest programs run here, off the host thread. The host can terminate() this
7
8
  // worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
8
9
  // code cannot take the harness down with it.
9
10
 
10
11
  const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
12
+
11
13
  const PARAMS = ["console", "read", "edit", "write", "bash"];
14
+
12
15
  const BODY_LINE_OFFSET = 2;
16
+
13
17
  /** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
14
18
  function guestLocation(err) {
15
19
  const stack = String(err?.stack);
16
20
  const m = /(?:<anonymous>:|(?:eval code|anonymous)(?:@|:))(\d+):(\d+)/.exec(stack);
21
+
17
22
  if (!m) return null;
18
23
  const line = Number(m[1]) - BODY_LINE_OFFSET;
24
+
19
25
  if (line < 1) return null;
26
+
20
27
  return { line, col: Number(m[2]) };
21
28
  }
22
29
 
23
30
  let activeRunId = 0;
31
+
24
32
  let runActive = false;
33
+
25
34
  let rpcSeq = 0;
35
+
26
36
  const pendingRpc = new Map();
27
37
 
28
38
  function post(msg) {
@@ -31,9 +41,11 @@ function post(msg) {
31
41
 
32
42
  function callRpc(runId, method, args) {
33
43
  if (!runActive || runId !== activeRunId) return Promise.reject(new Error("program is already complete"));
44
+
34
45
  return new Promise((resolve, reject) => {
35
46
  const id = ++rpcSeq;
36
47
  pendingRpc.set(id, { resolve, reject });
48
+
37
49
  try {
38
50
  post({ op: "rpc", id, runId, method, args });
39
51
  } catch (err) {
@@ -45,18 +57,23 @@ function callRpc(runId, method, args) {
45
57
 
46
58
  function unwrapValue(res) {
47
59
  if (res?.ok === false) throw new Error(String(res.value ?? res.error ?? "host tool failed"));
60
+
48
61
  if ("value" in Object(res)) return res.value;
62
+
49
63
  return res;
50
64
  }
51
65
 
52
66
  function unwrapRead(res, args) {
53
67
  const value = unwrapValue(res);
54
- if (args.complete === true && res?.truncated) throw new Error("incomplete read: complete:true refuses truncated host output");
68
+
69
+ if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
70
+
55
71
  return value;
56
72
  }
57
73
 
58
74
  function unwrapJsonValue(res) {
59
75
  const value = unwrapValue(res);
76
+
60
77
  try {
61
78
  return JSON.parse(value);
62
79
  } catch {
@@ -67,24 +84,33 @@ function unwrapJsonValue(res) {
67
84
  /** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
68
85
  function leanEnvelope(res) {
69
86
  if (!isObject(res)) return res;
87
+
70
88
  if ("details" in res) Object.defineProperty(res, "details", { value: res.details, enumerable: false, writable: true });
89
+
71
90
  for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key];
91
+
72
92
  if (res.truncated === false) delete res.truncated;
93
+
73
94
  return res;
74
95
  }
75
96
 
76
- function buildGuestApi(available, batchRead, runId, nativeArgv) {
97
+ function quoteShellArg(value) {
98
+ return "'" + String(value).replaceAll("'", "'\\''") + "'";
99
+ }
100
+
101
+ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
77
102
  const rpc = (method, args) => callRpc(runId, method, args);
78
103
  const availableSet = new Set(available);
79
104
  const checkpointScope = new AsyncLocalStorage();
80
105
  let checkpoint = null;
106
+
81
107
  const assertScope = () => {
82
108
  const token = checkpointScope.getStore();
109
+
83
110
  if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
84
111
  };
112
+
85
113
  const nova = {
86
- search: (query, limit) => rpc("search", [query, limit]),
87
- describe: (name) => rpc("describe", [name]),
88
114
  call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
89
115
  async callMany(calls) {
90
116
  const wave = await rpc("callMany", [calls]);
@@ -94,6 +120,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
94
120
  reason: { value: wave?.reason, enumerable: false },
95
121
  results: { value: results, enumerable: false },
96
122
  });
123
+
97
124
  return results;
98
125
  },
99
126
  async speculate(fn) {
@@ -101,6 +128,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
101
128
  const token = {};
102
129
  checkpoint = token;
103
130
  let began = false;
131
+
104
132
  try {
105
133
  flushReads();
106
134
  await rpc("speculateBegin", []);
@@ -108,10 +136,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
108
136
  const value = await checkpointScope.run(token, fn);
109
137
  flushReads();
110
138
  await rpc("speculateCommit", []);
139
+
111
140
  return { ok: true, committed: true, value };
112
141
  } catch (err) {
113
142
  flushReads();
143
+
114
144
  if (began) await rpc("speculateRollback", []);
145
+
115
146
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
116
147
  } finally { checkpoint = null; }
117
148
  },
@@ -123,120 +154,210 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
123
154
 
124
155
  // Coalesce already-started compatible reads without rewriting JS control flow.
125
156
  let queuedReads = [];
157
+
126
158
  function flushReads() {
127
159
  const pending = queuedReads;
128
160
  queuedReads = [];
161
+
129
162
  for (let start = 0; start < pending.length; start += 64) {
130
163
  const wave = pending.slice(start, start + 64);
131
164
  const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
165
+
132
166
  const run = wave.length === 1
133
167
  ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
134
- : nova.call("read", args).then(res => { unwrapRead(res, args); return { values: res.items, errors: res.itemErrors ?? [] }; });
168
+ : nova.call("read", args).then(res => { unwrapRead(res, args);
169
+
170
+ return { values: res.items, errors: res.itemErrors ?? [] }; });
171
+
135
172
  void run.then(({ values, errors }) => {
136
173
  if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
174
+
137
175
  for (let i = 0; i < wave.length; i++) {
138
176
  const value = values[i];
177
+
139
178
  if (errors[i]) wave[i].reject(new Error(errors[i]));
140
179
  else wave[i].resolve(value);
141
180
  }
142
181
  }).catch(error => { for (const job of wave) job.reject(error); });
143
182
  }
144
183
  }
145
- const invoke = (name, args) => { assertScope(); flushReads(); return nova.call(name, args); };
184
+
185
+ const invoke = (name, args) => { assertScope(); flushReads();
186
+
187
+ return nova.call(name, args); };
188
+
146
189
  const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
147
190
  ? { ...p, path: p.path ?? p.query }
148
191
  : isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
192
+
149
193
  const read = async (p, a, b) => {
150
194
  assertScope();
151
- const args = readArgs(p, a, b);
195
+ 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)) {
197
+ args.resolve = true;
198
+ }
199
+ validateJsonRead(args);
200
+ const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
201
+
152
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");
153
203
  const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
154
204
  p = args.path;
205
+
155
206
  if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
207
+
156
208
  if (args.outline) return unwrapJsonValue(await invoke("surface", args));
209
+
157
210
  if (Array.isArray(p)) {
158
211
  if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
159
- if (p.some(item => !isString(item) || !item.trim())) throw new Error("read paths must be non-empty strings");
212
+
213
+ for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
160
214
  const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
161
- if (!batchRead || args.resolve) return readEach();
215
+
216
+ if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
162
217
  const res = await invoke("read", args);
163
218
  const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
219
+
164
220
  if (failed >= 0) throw new Error(`read failed for ${p[failed]}: ${res.itemErrors[failed]}; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes`);
165
221
  unwrapRead(res, args);
166
- if (Array.isArray(res?.items)) return res.items;
222
+
223
+ if (Array.isArray(res?.items)) return res.items.map(decode);
224
+
167
225
  // Captured host executor without batch support: fan out.
168
226
  return readEach();
169
227
  }
228
+
170
229
  if (!batchRead) {
171
230
  const res = await invoke("read", args);
172
231
  unwrapRead(res, args);
173
- return args.resolve ? unwrapJsonValue(res) : unwrapRead(res, args);
232
+
233
+ return decode(unwrapRead(res, args));
174
234
  }
235
+
175
236
  const key = JSON.stringify({ ...args, path: undefined });
237
+
176
238
  if (queuedReads.length && queuedReads[0].key !== key) flushReads();
239
+
177
240
  return new Promise((resolve, reject) => {
178
241
  queuedReads.push({ args, key, resolve, reject });
242
+
179
243
  if (queuedReads.length === 1) queueMicrotask(flushReads);
180
- }).then(value => args.resolve ? JSON.parse(value) : value);
244
+ }).then(decode);
181
245
  };
246
+
182
247
  const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
248
+
249
+ const viewSpan = (value) => {
250
+ const start = isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
251
+ const end = isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
252
+
253
+ if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
254
+
255
+ return { start: Math.floor(start), end: Math.floor(end) };
256
+ };
257
+
258
+ 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);
259
+
183
260
  const edit = async (p, oldText, newText) => {
184
261
  if (isFunction(p)) return nova.speculate(p);
262
+ 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"})';
263
+
264
+ if (isView(p) && isString(oldText) && (newText === undefined || isString(newText))) {
265
+ if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
266
+ const span = viewSpan(p);
267
+ const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
268
+
269
+ if (newText !== undefined) args.oldText = oldText;
270
+
271
+ return unwrapValue(await invoke("edit", args));
272
+ }
273
+
274
+ if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
275
+
276
+ if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
185
277
  const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
278
+
279
+ if (!isString(args.path) || !args.path.trim()) throw new Error(usage);
280
+ const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
281
+
282
+ if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(usage);
283
+
284
+ if (args.patch !== undefined) {
285
+ if (!isString(args.patch) || !args.patch.trim()) throw new Error(usage);
286
+ } else {
287
+ const edits = args.edits === undefined ? [args] : args.edits;
288
+
289
+ if (!Array.isArray(edits) || !edits.length) throw new Error(usage);
290
+
291
+ for (const e of edits) if (!isString(e?.oldText) || !e.oldText.length || !isString(e?.newText)) throw new Error(usage + "; replacements require non-empty oldText and string newText");
292
+ }
293
+
186
294
  return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
187
295
  };
188
- const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
296
+
189
297
  const bash = async (command, opts) => {
190
298
  const args = isObject(command) ? { ...command } : { command, ...opts };
299
+
191
300
  if (args.args !== undefined) {
192
- 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");
193
- if (nativeArgv) args._directArgv = true;
194
- else {
301
+ if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
302
+
303
+ 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");
304
+
305
+ // Literal argv is a Supernova-owned contract. Host bash tools often ignore
306
+ // `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
307
+ if (process.platform === "win32") {
195
308
  delete args._directArgv;
196
309
  args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
197
- }
310
+ delete args.args;
311
+ } else args._directArgv = true;
198
312
  }
313
+
199
314
  command = args.command;
315
+
200
316
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
201
317
  const res = await invoke("bash", args);
318
+
202
319
  if (res?.ok === false) {
203
320
  let exitCode;
321
+
204
322
  try {
205
323
  exitCode = JSON.parse(res.details).exitCode;
206
324
  } catch {}
325
+
207
326
  const output = String(res.value).trimEnd();
208
327
  const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
209
328
  throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
210
329
  }
330
+
211
331
  let text = unwrapValue(res);
332
+
212
333
  if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
334
+
213
335
  return text;
214
336
  };
215
- const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
216
- const exec = async (cmd, args, opts) => {
217
- const command = String(cmd ?? "").trim();
218
- if (!command) throw new Error("exec requires command");
219
- // exec("git status") is a shell line; exec("git", ["status"]) is argv.
220
- if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
221
- return bash([command, ...args].map(quoteShellArg).join(" "), opts);
222
- };
223
337
 
224
- return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, evidence: nova.evidence, bash, exec, speculate: nova.speculate };
338
+ return { read, write, edit, bash };
225
339
  }
226
340
 
227
341
  function makeConsole(runId, limits) {
228
342
  let count = 0;
229
343
  let truncated = false;
344
+
230
345
  const markTruncated = () => {
231
346
  if (!truncated) post({ op: "logTruncated", runId });
232
347
  truncated = true;
233
348
  };
349
+
234
350
  const emit = (...args) => {
235
- if (count >= limits.maxLogLines) { markTruncated(); return; }
351
+ if (count >= limits.maxLogLines) { markTruncated();
352
+
353
+ return; }
354
+
236
355
  count++;
356
+
237
357
  const line = args
238
358
  .map((a) => {
239
359
  if (isString(a)) return a;
360
+
240
361
  try {
241
362
  return JSON.stringify(toPlain(a));
242
363
  } catch {
@@ -244,10 +365,13 @@ function makeConsole(runId, limits) {
244
365
  }
245
366
  })
246
367
  .join(" ");
368
+
247
369
  const clipped = truncateChars(line, limits.maxLogLineChars, "log");
370
+
248
371
  if (clipped.truncated) markTruncated();
249
372
  post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
250
373
  };
374
+
251
375
  return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
252
376
  }
253
377
 
@@ -262,25 +386,34 @@ async function handleRun(msg) {
262
386
  activeRunId = runId;
263
387
  runActive = true;
264
388
  let compiled;
389
+
265
390
  try {
266
- compiled = { fn: new AsyncFunction(...PARAMS, prepared.body), hasReturn: prepared.hasReturn };
391
+ // Existing programs may declare their own data variable; bind it only when supplied.
392
+ const bindings = msg.data === undefined ? PARAMS : [...PARAMS, "data"];
393
+ compiled = { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
267
394
  } catch (err) {
268
- postFailure(runId, err);
395
+ postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran. When passing data, do not redeclare its binding."));
396
+
269
397
  return;
270
398
  }
399
+
271
400
  const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
272
401
  const scopedConsole = makeConsole(runId, limits);
402
+
273
403
  try {
274
404
  const value = await compiled.fn(
275
- scopedConsole, api.read, api.edit, api.write, api.bash,
405
+ scopedConsole, api.read, api.edit, api.write, api.bash, msg.data,
276
406
  );
407
+
277
408
  if (runId !== activeRunId) return;
278
409
  let plain;
410
+
279
411
  try {
280
412
  plain = toPlain(value);
281
413
  } catch (err) {
282
414
  plain = "[unserializable: " + (err?.message || err) + "]";
283
415
  }
416
+
284
417
  runActive = false;
285
418
  post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
286
419
  } catch (err) {
@@ -291,14 +424,19 @@ async function handleRun(msg) {
291
424
 
292
425
  parentPort.on("message", (msg) => {
293
426
  if (!isObject(msg)) return;
427
+
294
428
  if (msg.op === "rpc:result") {
295
429
  const pending = pendingRpc.get(msg.id);
430
+
296
431
  if (!pending) return;
297
432
  pendingRpc.delete(msg.id);
433
+
298
434
  if (msg.ok) pending.resolve(msg.value);
299
435
  else pending.reject(new Error(msg.error));
436
+
300
437
  return;
301
438
  }
439
+
302
440
  if (msg.op === "run") void handleRun(msg);
303
441
  });
304
442