pi-supernova 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,71 @@ 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
 
54
74
  /** Lossless framing for source arrays, not string escaping or source compression. */
55
75
  export function formatReturn(value) {
56
76
  if (isString(value)) return value;
77
+
57
78
  if (Array.isArray(value) && value.length && hasWellFormedStrings(value) && value.some(text => text.includes("\n"))) {
58
79
  const raw = "strings[" + value.length + "]\n" + value.map((text, i) => "[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
59
80
  const escapedSize = value.reduce((sum, text) => sum + JSON.stringify(text).length, value.length + 1);
81
+
60
82
  if (raw.length < escapedSize) return raw;
61
83
  }
62
- return formatValue(value);
84
+
85
+ const escaped = formatValue(value);
86
+ const strings = [];
87
+
88
+ const visit = input => {
89
+ if (isString(input) && input.includes("\n") && !hasUnpairedSurrogate(input) && JSON.stringify(input).length - input.length > 64) {
90
+ const index = strings.push(input) - 1;
91
+
92
+ return { [RAW_TEXT]: "raw[" + index + "]" };
93
+ }
94
+
95
+ if (Array.isArray(input)) return input.map(visit);
96
+
97
+ if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visit(child)]));
98
+
99
+ return input;
100
+ };
101
+
102
+ const referencedValue = visit(value);
103
+
104
+ if (!strings.length) return escaped;
105
+ // Keep every key, value, duplicate string and byte. References are unquoted
106
+ // expressions, so literal "raw[0]" values and header-like source cannot collide.
107
+ const framed = formatValue(referencedValue) + "\nraw strings[" + strings.length + "]\n" + strings.map((text, i) => "raw[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
108
+
109
+ return framed.length < escaped.length ? framed : escaped;
63
110
  }
64
111
 
112
+ const RAW_TEXT = Symbol("raw text reference");
113
+
65
114
  const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
115
+
66
116
  const FORMAT_WIDTH = 120;
67
117
 
68
118
  function formatKey(key) {
@@ -71,26 +121,67 @@ function formatKey(key) {
71
121
 
72
122
  function formatPrimitive(value) {
73
123
  if (value === undefined) return "undefined";
124
+
74
125
  if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
126
+
75
127
  return JSON.stringify(value) ?? String(value);
76
128
  }
77
129
 
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 + "]";
130
+ /**
131
+ * Single-line rendering that gives up the moment it would exceed `limit` characters.
132
+ *
133
+ * formatValue consults this at every level, so the previous build-the-whole-string
134
+ * form re-walked each subtree once per ancestor: a container that is too wide paid
135
+ * the full cost of its children and then paid for them again while descending.
136
+ * Abandoning at the budget keeps the pass linear. Returning null is exactly
137
+ * equivalent to "the flat form is longer than limit", because an abandoned walk
138
+ * always has at least `used` characters still to come.
139
+ */
140
+ function formatFlatWithin(value, limit) {
141
+ const parts = [];
142
+ let used = 0;
143
+
144
+ const push = (chunk) => {
145
+ if (used + chunk.length > limit) return false;
146
+ used += chunk.length;
147
+ parts.push(chunk);
148
+
149
+ return true;
150
+ };
151
+
152
+ return walkFlat(value, push) ? parts.join("") : null;
83
153
  }
84
154
 
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]);
155
+ function walkFlat(value, push) {
156
+ if (value?.[RAW_TEXT] !== undefined) return push(value[RAW_TEXT]);
157
+
158
+ if (!isObject(value) && !Array.isArray(value)) return push(formatPrimitive(value));
159
+
160
+ if (Array.isArray(value)) {
161
+ if (value.length === 0) return push("[]");
162
+
163
+ if (!push("[")) return false;
164
+
165
+ for (let i = 0; i < value.length; i++) {
166
+ if (i && !push(",")) return false;
167
+
168
+ if (!walkFlat(value[i] === undefined ? null : value[i], push)) return false;
169
+ }
170
+
171
+ return push("]");
92
172
  }
93
- return out ? out + "}" : "{}";
173
+
174
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
175
+
176
+ if (keys.length === 0) return push("{}");
177
+
178
+ for (let i = 0; i < keys.length; i++) {
179
+ if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
180
+
181
+ if (!walkFlat(value[keys[i]], push)) return false;
182
+ }
183
+
184
+ return push("}");
94
185
  }
95
186
 
96
187
  /**
@@ -100,14 +191,23 @@ function formatFlat(value) {
100
191
  * than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
101
192
  */
102
193
  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;
194
+ if (value?.[RAW_TEXT] !== undefined) return value[RAW_TEXT];
195
+
196
+ if (!isObject(value) && !Array.isArray(value)) return formatPrimitive(value);
197
+ const flat = formatFlatWithin(value, width - indent.length);
198
+
199
+ if (flat !== null) return flat;
105
200
  const pad = indent + " ";
201
+
106
202
  if (Array.isArray(value)) {
107
203
  if (value.length === 0) return "[]";
204
+
108
205
  return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
109
206
  }
207
+
110
208
  const keys = Object.keys(value).filter((key) => value[key] !== undefined);
209
+
111
210
  if (keys.length === 0) return "{}";
211
+
112
212
  return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
113
213
  }
@@ -2,27 +2,37 @@ import { parentPort } from "node:worker_threads";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { isString, isObject, isFunction, toPlain } 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,12 +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
 
66
+ function unwrapRead(res, args) {
67
+ const value = unwrapValue(res);
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
+
71
+ return value;
72
+ }
73
+
52
74
  function unwrapJsonValue(res) {
53
75
  const value = unwrapValue(res);
76
+
54
77
  try {
55
78
  return JSON.parse(value);
56
79
  } catch {
@@ -61,9 +84,13 @@ function unwrapJsonValue(res) {
61
84
  /** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
62
85
  function leanEnvelope(res) {
63
86
  if (!isObject(res)) return res;
87
+
64
88
  if ("details" in res) Object.defineProperty(res, "details", { value: res.details, enumerable: false, writable: true });
89
+
65
90
  for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key];
91
+
66
92
  if (res.truncated === false) delete res.truncated;
93
+
67
94
  return res;
68
95
  }
69
96
 
@@ -72,10 +99,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
72
99
  const availableSet = new Set(available);
73
100
  const checkpointScope = new AsyncLocalStorage();
74
101
  let checkpoint = null;
102
+
75
103
  const assertScope = () => {
76
104
  const token = checkpointScope.getStore();
105
+
77
106
  if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
78
107
  };
108
+
79
109
  const nova = {
80
110
  search: (query, limit) => rpc("search", [query, limit]),
81
111
  describe: (name) => rpc("describe", [name]),
@@ -88,6 +118,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
88
118
  reason: { value: wave?.reason, enumerable: false },
89
119
  results: { value: results, enumerable: false },
90
120
  });
121
+
91
122
  return results;
92
123
  },
93
124
  async speculate(fn) {
@@ -95,6 +126,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
95
126
  const token = {};
96
127
  checkpoint = token;
97
128
  let began = false;
129
+
98
130
  try {
99
131
  flushReads();
100
132
  await rpc("speculateBegin", []);
@@ -102,10 +134,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
102
134
  const value = await checkpointScope.run(token, fn);
103
135
  flushReads();
104
136
  await rpc("speculateCommit", []);
137
+
105
138
  return { ok: true, committed: true, value };
106
139
  } catch (err) {
107
140
  flushReads();
141
+
108
142
  if (began) await rpc("speculateRollback", []);
143
+
109
144
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
110
145
  } finally { checkpoint = null; }
111
146
  },
@@ -117,97 +152,171 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
117
152
 
118
153
  // Coalesce already-started compatible reads without rewriting JS control flow.
119
154
  let queuedReads = [];
155
+
120
156
  function flushReads() {
121
157
  const pending = queuedReads;
122
158
  queuedReads = [];
159
+
123
160
  for (let start = 0; start < pending.length; start += 64) {
124
161
  const wave = pending.slice(start, start + 64);
125
162
  const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
163
+
126
164
  const run = wave.length === 1
127
- ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapValue(res)], errors: [] }))
128
- : nova.call("read", args).then(res => { unwrapValue(res); return { values: res.items, errors: res.itemErrors ?? [] }; });
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);
167
+
168
+ return { values: res.items, errors: res.itemErrors ?? [] }; });
169
+
129
170
  void run.then(({ values, errors }) => {
130
171
  if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
172
+
131
173
  for (let i = 0; i < wave.length; i++) {
132
174
  const value = values[i];
175
+
133
176
  if (errors[i]) wave[i].reject(new Error(errors[i]));
134
177
  else wave[i].resolve(value);
135
178
  }
136
179
  }).catch(error => { for (const job of wave) job.reject(error); });
137
180
  }
138
181
  }
139
- const invoke = (name, args) => { assertScope(); flushReads(); return nova.call(name, args); };
182
+
183
+ const invoke = (name, args) => { assertScope(); flushReads();
184
+
185
+ return nova.call(name, args); };
186
+
140
187
  const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
141
188
  ? { ...p, path: p.path ?? p.query }
142
189
  : isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
190
+
143
191
  const read = async (p, a, b) => {
144
192
  assertScope();
145
- const args = readArgs(p, a, b);
193
+ const args = sessionJsonArgs(readArgs(p, a, b));
194
+ validateJsonRead(args);
195
+ const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
196
+
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");
146
198
  const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
147
199
  p = args.path;
200
+
148
201
  if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
202
+
149
203
  if (args.outline) return unwrapJsonValue(await invoke("surface", args));
204
+
150
205
  if (Array.isArray(p)) {
151
206
  if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
152
- if (p.some(item => !isString(item) || !item.trim())) throw new Error("read paths must be non-empty strings");
153
- const readEach = () => Promise.all(p.map(async item => {
154
- try { return await read({ ...args, path: item }); }
155
- catch (error) { return `[read error: ${item}] ${error.message}`; }
156
- }));
157
- if (!batchRead || args.resolve) return readEach();
207
+
208
+ 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 })));
210
+
211
+ if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
158
212
  const res = await invoke("read", args);
159
- unwrapValue(res);
160
- if (Array.isArray(res?.items)) return res.items;
213
+ const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
214
+
215
+ 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`);
216
+ unwrapRead(res, args);
217
+
218
+ if (Array.isArray(res?.items)) return res.items.map(decode);
219
+
161
220
  // Captured host executor without batch support: fan out.
162
221
  return readEach();
163
222
  }
164
- if (!batchRead) return args.resolve ? unwrapJsonValue(await invoke("read", args)) : unwrapValue(await invoke("read", args));
223
+
224
+ if (!batchRead) {
225
+ const res = await invoke("read", args);
226
+ unwrapRead(res, args);
227
+
228
+ return decode(unwrapRead(res, args));
229
+ }
230
+
165
231
  const key = JSON.stringify({ ...args, path: undefined });
232
+
166
233
  if (queuedReads.length && queuedReads[0].key !== key) flushReads();
234
+
167
235
  return new Promise((resolve, reject) => {
168
236
  queuedReads.push({ args, key, resolve, reject });
237
+
169
238
  if (queuedReads.length === 1) queueMicrotask(flushReads);
170
- }).then(value => args.resolve ? JSON.parse(value) : value);
239
+ }).then(decode);
171
240
  };
241
+
172
242
  const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
243
+
173
244
  const edit = async (p, oldText, newText) => {
174
245
  if (isFunction(p)) return nova.speculate(p);
246
+ 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
+
248
+ if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
249
+
250
+ if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
175
251
  const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
252
+
253
+ if (!isString(args.path) || !args.path.trim()) throw new Error(usage);
254
+ const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
255
+
256
+ if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(usage);
257
+
258
+ if (args.patch !== undefined) {
259
+ if (!isString(args.patch) || !args.patch.trim()) throw new Error(usage);
260
+ } else {
261
+ const edits = args.edits === undefined ? [args] : args.edits;
262
+
263
+ if (!Array.isArray(edits) || !edits.length) throw new Error(usage);
264
+
265
+ 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");
266
+ }
267
+
176
268
  return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
177
269
  };
270
+
178
271
  const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
272
+
179
273
  const bash = async (command, opts) => {
180
274
  const args = isObject(command) ? { ...command } : { command, ...opts };
275
+
181
276
  if (args.args !== undefined) {
182
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");
278
+
183
279
  if (nativeArgv) args._directArgv = true;
184
280
  else {
185
281
  delete args._directArgv;
186
282
  args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
187
283
  }
188
284
  }
285
+
189
286
  command = args.command;
287
+
190
288
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
191
289
  const res = await invoke("bash", args);
290
+
192
291
  if (res?.ok === false) {
193
292
  let exitCode;
293
+
194
294
  try {
195
295
  exitCode = JSON.parse(res.details).exitCode;
196
296
  } catch {}
297
+
197
298
  const output = String(res.value).trimEnd();
198
299
  const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
199
300
  throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
200
301
  }
302
+
201
303
  let text = unwrapValue(res);
304
+
202
305
  if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
306
+
203
307
  return text;
204
308
  };
309
+
205
310
  const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
311
+
206
312
  const exec = async (cmd, args, opts) => {
207
313
  const command = String(cmd ?? "").trim();
314
+
208
315
  if (!command) throw new Error("exec requires command");
316
+
209
317
  // exec("git status") is a shell line; exec("git", ["status"]) is argv.
210
318
  if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
319
+
211
320
  return bash([command, ...args].map(quoteShellArg).join(" "), opts);
212
321
  };
213
322
 
@@ -217,16 +326,23 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
217
326
  function makeConsole(runId, limits) {
218
327
  let count = 0;
219
328
  let truncated = false;
329
+
220
330
  const markTruncated = () => {
221
331
  if (!truncated) post({ op: "logTruncated", runId });
222
332
  truncated = true;
223
333
  };
334
+
224
335
  const emit = (...args) => {
225
- if (count >= limits.maxLogLines) { markTruncated(); return; }
336
+ if (count >= limits.maxLogLines) { markTruncated();
337
+
338
+ return; }
339
+
226
340
  count++;
341
+
227
342
  const line = args
228
343
  .map((a) => {
229
344
  if (isString(a)) return a;
345
+
230
346
  try {
231
347
  return JSON.stringify(toPlain(a));
232
348
  } catch {
@@ -234,10 +350,13 @@ function makeConsole(runId, limits) {
234
350
  }
235
351
  })
236
352
  .join(" ");
353
+
237
354
  const clipped = truncateChars(line, limits.maxLogLineChars, "log");
355
+
238
356
  if (clipped.truncated) markTruncated();
239
357
  post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
240
358
  };
359
+
241
360
  return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
242
361
  }
243
362
 
@@ -252,25 +371,34 @@ async function handleRun(msg) {
252
371
  activeRunId = runId;
253
372
  runActive = true;
254
373
  let compiled;
374
+
255
375
  try {
256
- compiled = { fn: new AsyncFunction(...PARAMS, prepared.body), hasReturn: prepared.hasReturn };
376
+ // Existing programs may declare their own data variable; bind it only when supplied.
377
+ const bindings = msg.data === undefined ? PARAMS : [...PARAMS, "data"];
378
+ compiled = { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
257
379
  } catch (err) {
258
- postFailure(runId, err);
380
+ postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran. When passing data, do not redeclare its binding."));
381
+
259
382
  return;
260
383
  }
384
+
261
385
  const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
262
386
  const scopedConsole = makeConsole(runId, limits);
387
+
263
388
  try {
264
389
  const value = await compiled.fn(
265
- scopedConsole, api.read, api.edit, api.write, api.bash,
390
+ scopedConsole, api.read, api.edit, api.write, api.bash, msg.data,
266
391
  );
392
+
267
393
  if (runId !== activeRunId) return;
268
394
  let plain;
395
+
269
396
  try {
270
397
  plain = toPlain(value);
271
398
  } catch (err) {
272
399
  plain = "[unserializable: " + (err?.message || err) + "]";
273
400
  }
401
+
274
402
  runActive = false;
275
403
  post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
276
404
  } catch (err) {
@@ -281,14 +409,19 @@ async function handleRun(msg) {
281
409
 
282
410
  parentPort.on("message", (msg) => {
283
411
  if (!isObject(msg)) return;
412
+
284
413
  if (msg.op === "rpc:result") {
285
414
  const pending = pendingRpc.get(msg.id);
415
+
286
416
  if (!pending) return;
287
417
  pendingRpc.delete(msg.id);
418
+
288
419
  if (msg.ok) pending.resolve(msg.value);
289
420
  else pending.reject(new Error(msg.error));
421
+
290
422
  return;
291
423
  }
424
+
292
425
  if (msg.op === "run") void handleRun(msg);
293
426
  });
294
427