pi-supernova 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,9 +84,13 @@ 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
 
@@ -78,10 +99,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
78
99
  const availableSet = new Set(available);
79
100
  const checkpointScope = new AsyncLocalStorage();
80
101
  let checkpoint = null;
102
+
81
103
  const assertScope = () => {
82
104
  const token = checkpointScope.getStore();
105
+
83
106
  if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
84
107
  };
108
+
85
109
  const nova = {
86
110
  search: (query, limit) => rpc("search", [query, limit]),
87
111
  describe: (name) => rpc("describe", [name]),
@@ -94,6 +118,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
94
118
  reason: { value: wave?.reason, enumerable: false },
95
119
  results: { value: results, enumerable: false },
96
120
  });
121
+
97
122
  return results;
98
123
  },
99
124
  async speculate(fn) {
@@ -101,6 +126,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
101
126
  const token = {};
102
127
  checkpoint = token;
103
128
  let began = false;
129
+
104
130
  try {
105
131
  flushReads();
106
132
  await rpc("speculateBegin", []);
@@ -108,10 +134,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
108
134
  const value = await checkpointScope.run(token, fn);
109
135
  flushReads();
110
136
  await rpc("speculateCommit", []);
137
+
111
138
  return { ok: true, committed: true, value };
112
139
  } catch (err) {
113
140
  flushReads();
141
+
114
142
  if (began) await rpc("speculateRollback", []);
143
+
115
144
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
116
145
  } finally { checkpoint = null; }
117
146
  },
@@ -123,101 +152,171 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
123
152
 
124
153
  // Coalesce already-started compatible reads without rewriting JS control flow.
125
154
  let queuedReads = [];
155
+
126
156
  function flushReads() {
127
157
  const pending = queuedReads;
128
158
  queuedReads = [];
159
+
129
160
  for (let start = 0; start < pending.length; start += 64) {
130
161
  const wave = pending.slice(start, start + 64);
131
162
  const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
163
+
132
164
  const run = wave.length === 1
133
165
  ? 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 ?? [] }; });
166
+ : nova.call("read", args).then(res => { unwrapRead(res, args);
167
+
168
+ return { values: res.items, errors: res.itemErrors ?? [] }; });
169
+
135
170
  void run.then(({ values, errors }) => {
136
171
  if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
172
+
137
173
  for (let i = 0; i < wave.length; i++) {
138
174
  const value = values[i];
175
+
139
176
  if (errors[i]) wave[i].reject(new Error(errors[i]));
140
177
  else wave[i].resolve(value);
141
178
  }
142
179
  }).catch(error => { for (const job of wave) job.reject(error); });
143
180
  }
144
181
  }
145
- 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
+
146
187
  const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
147
188
  ? { ...p, path: p.path ?? p.query }
148
189
  : isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
190
+
149
191
  const read = async (p, a, b) => {
150
192
  assertScope();
151
- 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
+
152
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");
153
198
  const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
154
199
  p = args.path;
200
+
155
201
  if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
202
+
156
203
  if (args.outline) return unwrapJsonValue(await invoke("surface", args));
204
+
157
205
  if (Array.isArray(p)) {
158
206
  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");
207
+
208
+ for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
160
209
  const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
161
- if (!batchRead || args.resolve) return readEach();
210
+
211
+ if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
162
212
  const res = await invoke("read", args);
163
213
  const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
214
+
164
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`);
165
216
  unwrapRead(res, args);
166
- if (Array.isArray(res?.items)) return res.items;
217
+
218
+ if (Array.isArray(res?.items)) return res.items.map(decode);
219
+
167
220
  // Captured host executor without batch support: fan out.
168
221
  return readEach();
169
222
  }
223
+
170
224
  if (!batchRead) {
171
225
  const res = await invoke("read", args);
172
226
  unwrapRead(res, args);
173
- return args.resolve ? unwrapJsonValue(res) : unwrapRead(res, args);
227
+
228
+ return decode(unwrapRead(res, args));
174
229
  }
230
+
175
231
  const key = JSON.stringify({ ...args, path: undefined });
232
+
176
233
  if (queuedReads.length && queuedReads[0].key !== key) flushReads();
234
+
177
235
  return new Promise((resolve, reject) => {
178
236
  queuedReads.push({ args, key, resolve, reject });
237
+
179
238
  if (queuedReads.length === 1) queueMicrotask(flushReads);
180
- }).then(value => args.resolve ? JSON.parse(value) : value);
239
+ }).then(decode);
181
240
  };
241
+
182
242
  const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
243
+
183
244
  const edit = async (p, oldText, newText) => {
184
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);
185
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
+
186
268
  return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
187
269
  };
270
+
188
271
  const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
272
+
189
273
  const bash = async (command, opts) => {
190
274
  const args = isObject(command) ? { ...command } : { command, ...opts };
275
+
191
276
  if (args.args !== undefined) {
192
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
+
193
279
  if (nativeArgv) args._directArgv = true;
194
280
  else {
195
281
  delete args._directArgv;
196
282
  args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
197
283
  }
198
284
  }
285
+
199
286
  command = args.command;
287
+
200
288
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
201
289
  const res = await invoke("bash", args);
290
+
202
291
  if (res?.ok === false) {
203
292
  let exitCode;
293
+
204
294
  try {
205
295
  exitCode = JSON.parse(res.details).exitCode;
206
296
  } catch {}
297
+
207
298
  const output = String(res.value).trimEnd();
208
299
  const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
209
300
  throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
210
301
  }
302
+
211
303
  let text = unwrapValue(res);
304
+
212
305
  if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
306
+
213
307
  return text;
214
308
  };
309
+
215
310
  const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
311
+
216
312
  const exec = async (cmd, args, opts) => {
217
313
  const command = String(cmd ?? "").trim();
314
+
218
315
  if (!command) throw new Error("exec requires command");
316
+
219
317
  // exec("git status") is a shell line; exec("git", ["status"]) is argv.
220
318
  if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
319
+
221
320
  return bash([command, ...args].map(quoteShellArg).join(" "), opts);
222
321
  };
223
322
 
@@ -227,16 +326,23 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
227
326
  function makeConsole(runId, limits) {
228
327
  let count = 0;
229
328
  let truncated = false;
329
+
230
330
  const markTruncated = () => {
231
331
  if (!truncated) post({ op: "logTruncated", runId });
232
332
  truncated = true;
233
333
  };
334
+
234
335
  const emit = (...args) => {
235
- if (count >= limits.maxLogLines) { markTruncated(); return; }
336
+ if (count >= limits.maxLogLines) { markTruncated();
337
+
338
+ return; }
339
+
236
340
  count++;
341
+
237
342
  const line = args
238
343
  .map((a) => {
239
344
  if (isString(a)) return a;
345
+
240
346
  try {
241
347
  return JSON.stringify(toPlain(a));
242
348
  } catch {
@@ -244,10 +350,13 @@ function makeConsole(runId, limits) {
244
350
  }
245
351
  })
246
352
  .join(" ");
353
+
247
354
  const clipped = truncateChars(line, limits.maxLogLineChars, "log");
355
+
248
356
  if (clipped.truncated) markTruncated();
249
357
  post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
250
358
  };
359
+
251
360
  return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
252
361
  }
253
362
 
@@ -262,25 +371,34 @@ async function handleRun(msg) {
262
371
  activeRunId = runId;
263
372
  runActive = true;
264
373
  let compiled;
374
+
265
375
  try {
266
- 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 };
267
379
  } catch (err) {
268
- 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
+
269
382
  return;
270
383
  }
384
+
271
385
  const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
272
386
  const scopedConsole = makeConsole(runId, limits);
387
+
273
388
  try {
274
389
  const value = await compiled.fn(
275
- scopedConsole, api.read, api.edit, api.write, api.bash,
390
+ scopedConsole, api.read, api.edit, api.write, api.bash, msg.data,
276
391
  );
392
+
277
393
  if (runId !== activeRunId) return;
278
394
  let plain;
395
+
279
396
  try {
280
397
  plain = toPlain(value);
281
398
  } catch (err) {
282
399
  plain = "[unserializable: " + (err?.message || err) + "]";
283
400
  }
401
+
284
402
  runActive = false;
285
403
  post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
286
404
  } catch (err) {
@@ -291,14 +409,19 @@ async function handleRun(msg) {
291
409
 
292
410
  parentPort.on("message", (msg) => {
293
411
  if (!isObject(msg)) return;
412
+
294
413
  if (msg.op === "rpc:result") {
295
414
  const pending = pendingRpc.get(msg.id);
415
+
296
416
  if (!pending) return;
297
417
  pendingRpc.delete(msg.id);
418
+
298
419
  if (msg.ok) pending.resolve(msg.value);
299
420
  else pending.reject(new Error(msg.error));
421
+
300
422
  return;
301
423
  }
424
+
302
425
  if (msg.op === "run") void handleRun(msg);
303
426
  });
304
427