pi-supernova 0.5.0 → 0.7.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.
Files changed (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -1,33 +1,94 @@
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
- export function isMutatingTool(name, config = {}, args = {}, definition) {
7
+ const NATIVE_TOOLS = ["read", "edit", "write", "bash"];
8
+
9
+ const SPECIAL_MUTATION = {
10
+ todo: args => args.op !== "view",
11
+ hub: args => !["list", "ps", "logs", "describe"].includes(args.op),
12
+ };
13
+
14
+ function configuredMutating(name, config) {
8
15
  if ((config.mutatingTools ?? []).includes(name)) return true;
9
16
 
10
17
  if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
11
18
 
19
+ return false;
20
+ }
21
+
22
+ function lspMutates(args) {
23
+ const action = args.action ?? args.operation;
24
+
25
+ if (READ_ONLY_LSP.has(action)) return false;
26
+
27
+ if (["rename", "rename_file"].includes(action)) return args.apply !== false;
28
+
29
+ if (action === "code_actions") return args.apply === true;
30
+
31
+ return true;
32
+ }
33
+
34
+ export function isMutatingTool(name, config = {}, args = {}, definition) {
35
+ if (!isObject(args)) args = {};
36
+ name = String(name);
37
+
38
+ if (configuredMutating(name, config)) return true;
39
+
12
40
  if (definition?.annotations?.readOnlyHint === true) return false;
13
41
 
14
- if (name === "lsp") {
15
- const action = args.action ?? args.operation;
42
+ if (name === "lsp") return lspMutates(args);
43
+ const special = SPECIAL_MUTATION[name];
16
44
 
17
- if (READ_ONLY_LSP.has(action)) return false;
45
+ if (special) return special(args);
46
+
47
+ return !READ_ONLY_TOOLS.has(name);
48
+ }
18
49
 
19
- if (["rename", "rename_file"].includes(action)) return args.apply !== false;
50
+ function takeScheduledWave(queue, first, maxParallelReads) {
51
+ const wave = [first];
20
52
 
21
- if (action === "code_actions") return args.apply === true;
53
+ if (first.name !== "read") return wave;
22
54
 
23
- return true;
55
+ while (wave.length < maxParallelReads && queue[0]?.name === "read") {
56
+ const next = queue.shift();
57
+
58
+ if (!next.cancelled) wave.push(next);
24
59
  }
25
60
 
26
- if (name === "todo") return args.op !== "view";
61
+ return wave;
62
+ }
27
63
 
28
- if (name === "hub") return !["list", "ps", "logs", "describe"].includes(args.op);
64
+ async function runScheduledJob(job) {
65
+ if (job.cancelled) return;
66
+ job.started = true;
67
+ job.signal?.removeEventListener("abort", job.abort);
68
+
69
+ try {
70
+ job.signal?.throwIfAborted();
71
+ const result = await job.run();
72
+ job.signal?.throwIfAborted();
73
+ job.resolve(result);
74
+ } catch (error) { job.reject(error); }
75
+ }
29
76
 
30
- return !READ_ONLY_TOOLS.has(name);
77
+ function runScheduledWave(wave) {
78
+ // Settle each promise separately: one failed read must not discard its
79
+ // siblings or release a write barrier while other reads are still active.
80
+ return Promise.all(wave.map(runScheduledJob));
81
+ }
82
+
83
+ function attachJobAbort(job, signal, reject) {
84
+ job.abort = () => {
85
+ if (job.started) return;
86
+ job.cancelled = true;
87
+ signal.removeEventListener("abort", job.abort);
88
+ reject(signal.reason ?? new Error("aborted"));
89
+ };
90
+
91
+ signal?.addEventListener("abort", job.abort, { once: true });
31
92
  }
32
93
 
33
94
  /** FIFO read waves with mutation barriers; callers keep independent promises. */
@@ -43,33 +104,14 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
43
104
  const first = queue.shift();
44
105
 
45
106
  if (first.cancelled) continue;
46
- const wave = [first];
107
+ const wave = takeScheduledWave(queue, first, maxParallelReads);
47
108
 
48
109
  if (first.name === "read") {
49
- while (wave.length < maxParallelReads && queue[0]?.name === "read") {
50
- const next = queue.shift();
51
-
52
- if (!next.cancelled) wave.push(next);
53
- }
54
-
55
110
  stats.readWaves++;
56
111
  stats.peakParallelReads = Math.max(stats.peakParallelReads, wave.length);
57
112
  }
58
113
 
59
- // Settle each promise separately: one failed read must not discard its
60
- // siblings or release a write barrier while other reads are still active.
61
- await Promise.all(wave.map(async job => {
62
- if (job.cancelled) return;
63
- job.started = true;
64
- job.signal?.removeEventListener("abort", job.abort);
65
-
66
- try {
67
- job.signal?.throwIfAborted();
68
- const result = await job.run();
69
- job.signal?.throwIfAborted();
70
- job.resolve(result);
71
- } catch (error) { job.reject(error); }
72
- }));
114
+ await runScheduledWave(wave);
73
115
  }
74
116
  } finally { draining = false; }
75
117
  }
@@ -77,7 +119,7 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
77
119
  return {
78
120
  stats,
79
121
  schedule(name, run, signal) {
80
- if (!["read", "edit", "write", "bash"].includes(name) || !isFunction(run)) {
122
+ if (!NATIVE_TOOLS.includes(name) || !isFunction(run)) {
81
123
  return Promise.reject(new Error("scheduler requires a native tool and an executor"));
82
124
  }
83
125
 
@@ -86,14 +128,7 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
86
128
 
87
129
  return new Promise((resolve, reject) => {
88
130
  const job = { name, run, signal, resolve, reject, started: false, cancelled: false };
89
- job.abort = () => {
90
- if (job.started) return;
91
- job.cancelled = true;
92
- signal.removeEventListener("abort", job.abort);
93
- reject(signal.reason ?? new Error("aborted"));
94
- };
95
-
96
- signal?.addEventListener("abort", job.abort, { once: true });
131
+ attachJobAbort(job, signal, reject);
97
132
  queue.push(job);
98
133
 
99
134
  if (!draining) {
@@ -113,26 +148,27 @@ function requireArray(value, name) {
113
148
  return value;
114
149
  }
115
150
 
116
- export async function runParallelWave(thunks, meta, options = {}) {
117
- const list = requireArray(thunks, "parallel wave");
151
+ function waveMutates(list, meta, config) {
152
+ const names = meta?.names ?? [];
118
153
 
119
- if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
154
+ return names.length !== list.length || names.some((name, i) => isMutatingTool(name, config, meta?.calls?.[i]?.args, meta?.definitions?.[i]));
155
+ }
120
156
 
121
- if (!list.length) return { results: [], mode: "serial", reason: "empty" };
122
- const { mode = "auto", config = {} } = options;
123
- const names = meta?.names ?? [];
124
- const mutating = names.length !== list.length || names.some((name, i) => isMutatingTool(name, config, meta?.calls?.[i]?.args, meta?.definitions?.[i]));
157
+ function shouldRunParallel(mutating, mode, length) {
158
+ return !mutating && (mode === "parallel" || (mode === "auto" && length > 1));
159
+ }
125
160
 
126
- if (!mutating && (mode === "parallel" || (mode === "auto" && list.length > 1))) {
127
- // Do not finish a wave while its already-started host calls are still running.
128
- const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
129
- const failure = settled.find(item => item.status === "rejected");
161
+ async function settleParallel(list) {
162
+ // Do not finish a wave while its already-started host calls are still running.
163
+ const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
164
+ const failure = settled.find(item => item.status === "rejected");
130
165
 
131
- if (failure) throw failure.reason;
166
+ if (failure) throw failure.reason;
132
167
 
133
- return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
134
- }
168
+ return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
169
+ }
135
170
 
171
+ async function runSerial(list, mutating) {
136
172
  const results = [];
137
173
 
138
174
  for (const thunk of list) results.push(await thunk());
@@ -140,16 +176,16 @@ export async function runParallelWave(thunks, meta, options = {}) {
140
176
  return { results, mode: "serial", reason: mutating ? "mutating" : "single-or-forced" };
141
177
  }
142
178
 
143
- export async function parallel(items) {
144
- return Promise.all(requireArray(items, "parallel").map(item => isFunction(item) ? item() : item));
145
- }
179
+ export async function runParallelWave(thunks, meta, options = {}) {
180
+ const list = requireArray(thunks, "parallel wave");
146
181
 
147
- export async function pipeline(items, ...stages) {
148
- let current = requireArray(items, "pipeline");
182
+ if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
149
183
 
150
- if (stages.some(stage => !isFunction(stage))) throw new TypeError("pipeline stages must be functions");
184
+ if (!list.length) return { results: [], mode: "serial", reason: "empty" };
185
+ const { mode = "auto", config = {} } = options;
186
+ const mutating = waveMutates(list, meta, config);
151
187
 
152
- for (const stage of stages) current = await Promise.all(current.map(item => stage(item)));
188
+ if (shouldRunParallel(mutating, mode, list.length)) return settleParallel(list);
153
189
 
154
- return current;
190
+ return runSerial(list, mutating);
155
191
  }
@@ -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;
@@ -12,10 +12,15 @@ const mutationTotals = results => results.reduce((total, result) => {
12
12
  return total;
13
13
  }, {committed:0,rolledBack:0,external:0,pendingCommits:0,recoveryFailed:false});
14
14
 
15
- export function programBatchText(results, total, stopped = "") {
15
+ export function programBatchText(results, total, stopped = "", failed = 0) {
16
16
  const m = mutationTotals(results);
17
+ const summary = stopped
18
+ ? "error: programs stopped: " + stopped + " " + results.length + "/" + total
19
+ : failed > 0
20
+ ? "error: programs " + results.length + "/" + total + " - " + failed + " failed"
21
+ : "ok: programs " + results.length + "/" + total;
17
22
 
18
- return (stopped ? "error: programs stopped: " + stopped : "ok: programs") + " " + results.length + "/" + total +
23
+ return summary +
19
24
  (m.recoveryFailed || m.pendingCommits ? "; filesystem outcome uncertain: inspect disk" : "") + "\nresults (UTF-16 lengths):\n" + results.map((result,i) => {
20
25
  const text = textOf(result);
21
26
 
@@ -23,90 +28,205 @@ export function programBatchText(results, total, stopped = "") {
23
28
  }).join("");
24
29
  }
25
30
 
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");
28
-
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
-
31
- for (const p of params.programs) {
32
- if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
33
- ((p.code === undefined) === (p.file === undefined)) || !isString(p.code ?? p.file) || !(p.code ?? p.file).trim()) {
34
- throw new Error("each program requires code OR file, with optional data; no nested batches or per-entry timeouts; no programs ran");
35
- }
31
+ function assertProgramEntry(p) {
32
+ if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
33
+ ((p.code === undefined) === (p.file === undefined)) || !isString(p.code ?? p.file) || !(p.code ?? p.file).trim()) {
34
+ throw new Error("each program requires code OR file, with optional data; no nested batches or per-entry timeouts; no programs ran");
36
35
  }
36
+ }
37
37
 
38
+ function encodeBatchPayload(params) {
39
+ const hasDefault = params.data !== undefined;
38
40
  let encoded;
39
41
 
40
- try { encoded = JSON.stringify(params.programs); } catch { throw new Error("programs must be JSON-serializable; no programs ran"); }
42
+ 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"); }
43
+
44
+ return { hasDefault, encoded };
45
+ }
46
+
47
+ function applyDefaultData(parsed, hasDefault) {
48
+ if (!hasDefault) return parsed;
49
+ if (!Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
50
+
51
+ // The runtime snapshots data separately for each fresh guest. An explicit
52
+ // entry replaces the default wholesale; falsy values are not missing values.
53
+ return parsed.programs.map(program => program.data === undefined ? {...program,data:parsed.data} : program);
54
+ }
55
+
56
+ function parseBatchPayload(params, config) {
57
+ if (["code","file"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code or file; no programs ran");
58
+
59
+ if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
60
+
61
+ for (const p of params.programs) assertProgramEntry(p);
62
+ const { hasDefault, encoded } = encodeBatchPayload(params);
41
63
 
42
64
  if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
43
65
 
44
- return JSON.parse(encoded);
66
+ return applyDefaultData(JSON.parse(encoded), hasDefault);
45
67
  }
46
68
 
47
- /** Explicit known continuations, not inferred plans, retries, or a shared heap. */
48
- export async function runProgramBatch(id, params, signal, onUpdate, ctx, config, execute) {
49
- const programs = batchInputs(params,config);
50
- const started = performance.now();
51
- const timeout = Number.isInteger(params.timeoutMs) ? params.timeoutMs : config.timeoutMs;
52
- const deadline = started + timeout;
53
- const controller = new AbortController();
54
- const combined = signal ? AbortSignal.any([signal,controller.signal]) : controller.signal;
55
- const timer = setTimeout(() => controller.abort(),Math.min(timeout,2147483647));
56
- const budget = {calls:0,logLines:0};
57
- const results = [], images = [], imageLabels = [], trace = [];
58
- const imageTextChars = () => imageLabels.reduce((chars,label)=>chars+label.length+1,0);
59
- let imageBytes = 0, stopped = "";
60
-
61
- try {
62
- for (const [i, program] of programs.entries()) {
63
- if (combined.aborted || performance.now() >= deadline) { stopped = "deadline or cancellation; remaining programs did not run"; break; }
64
-
65
- let result;
66
-
67
- try {
68
- result = await execute(id + ":" + i,{...program,timeoutMs:Math.max(1,Math.ceil(deadline-performance.now()))},combined,update => {
69
- try { onUpdate?.({...update,details:{...update.details,trace:[...trace,...(update.details?.trace ?? [])]}}); } catch {}
70
- },ctx,budget);
71
- } catch (error) {
72
- result = error.supernovaResult ?? {content:[{type:"text",text:String(error.message ?? error)}],details:{ok:false,error:String(error.message ?? error)}};
73
- }
69
+ function batchTimeoutMs(params, config) {
70
+ const requestedTimeout = params.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
71
+
72
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("program batch timeoutMs must be a positive finite number");
73
+
74
+ return requestedTimeout;
75
+ }
76
+
77
+ const MAX_PARALLEL_PROGRAMS = 8;
78
+
79
+ class ProgramBatch {
80
+ constructor(id, params, signal, onUpdate, ctx, config, execute, programs, timeout) {
81
+ this.id = id;
82
+ this.onUpdate = onUpdate;
83
+ this.ctx = ctx;
84
+ this.config = config;
85
+ this.execute = execute;
86
+ this.programs = programs;
87
+ this.timeout = timeout;
88
+ this.started = performance.now();
89
+ this.deadline = this.started + timeout;
90
+ this.controller = new AbortController();
91
+ this.combined = signal ? AbortSignal.any([signal, this.controller.signal]) : this.controller.signal;
92
+ this.timer = setTimeout(() => this.controller.abort(), Math.min(timeout, 2147483647));
93
+ this.budget = {calls:0,logLines:0};
94
+ this.parallel = params.parallel === true && programs.length > 1;
95
+ this.results = [];
96
+ this.images = [];
97
+ this.imageLabels = [];
98
+ this.trace = [];
99
+ this.live = programs.map(() => []);
100
+ this.imageSeq = programs.map(() => 0);
101
+ this.imageBytes = 0;
102
+ this.stopped = "";
103
+ this.imageDropped = false;
104
+ }
105
+
106
+ imageTextChars() {
107
+ return this.imageLabels.reduce((chars, label) => chars + label.length + 1, 0);
108
+ }
109
+
110
+ updateFor(i) {
111
+ return update => {
112
+ this.live[i] = update?.details?.trace ?? [];
113
+
114
+ try { this.onUpdate?.({...update,details:{...update?.details,trace:[...this.trace,...this.live.flat()]}}); } catch {}
115
+ };
116
+ }
117
+
118
+ runOne(program, i) {
119
+ return Promise.resolve()
120
+ .then(() => this.execute(this.id + ":" + i,{...program,timeoutMs:Math.max(1,Math.ceil(this.deadline-performance.now()))},this.combined,this.updateFor(i),this.ctx,this.budget,{parallel:this.parallel}))
121
+ .catch(error => error.supernovaResult ?? {content:[{type:"text",text:String(error?.message ?? error)}],details:{ok:false,error:String(error?.message ?? error)}});
122
+ }
123
+
124
+ collectImages(result, i) {
125
+ for (const block of Array.isArray(result?.content) ? result.content : []) if (block?.type === "image" && isString(block.data)) {
126
+ this.imageBytes += Buffer.byteLength(block.data,"base64");
74
127
 
75
- results.push(result);
76
- trace.push(...(result.details?.trace ?? []));
77
- let image = 0;
128
+ if (this.images.length >= 16 || this.imageBytes > 20*1024*1024) { this.imageDropped = true; continue; }
78
129
 
79
- for (const block of result.content) if (block.type === "image") {
80
- imageBytes += Buffer.byteLength(block.data,"base64");
130
+ this.images.push(block);
131
+ this.imageLabels.push("program " + (i+1) + " image " + (++this.imageSeq[i]));
132
+ }
133
+ }
134
+
135
+ takeSettled(result, i) {
136
+ this.results.push(result);
137
+ this.trace.push(...(result.details?.trace ?? []));
138
+ this.collectImages(result, i);
139
+ }
81
140
 
82
- if (images.length >= 16 || imageBytes > 20*1024*1024) { stopped = "batch image budget exceeded; remaining programs did not run"; break; }
141
+ async runParallel() {
142
+ const limit = Math.min(this.programs.length, MAX_PARALLEL_PROGRAMS);
143
+ const settled = Array.from({ length: this.programs.length });
144
+ let next = 0;
83
145
 
84
- images.push(block);
85
- imageLabels.push("program " + (i+1) + " image " + (++image));
146
+ await Promise.all(Array.from({length: limit}, async () => {
147
+ while (next < this.programs.length && !this.combined.aborted && performance.now() < this.deadline) {
148
+ const i = next++;
149
+ settled[i] = await this.runOne(this.programs[i], i);
150
+ this.live[i] = [];
86
151
  }
152
+ }));
153
+
154
+ for (let i = 0; i < this.programs.length; i++) {
155
+ if (settled[i] === undefined) continue;
156
+ this.takeSettled(settled[i], i);
157
+ }
158
+
159
+ if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain";
160
+ }
87
161
 
88
- if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
89
- const text = programBatchText(results,programs.length,stopped);
162
+ sequentialStop(result, i) {
163
+ let stopped = this.stopped;
90
164
 
91
- if (text.length + imageTextChars() > config.maxReturnChars || result.details?.returnTruncated) stopped ||= "batch output budget exceeded; remaining programs did not run; earlier commits remain";
165
+ if (this.imageDropped) stopped = "batch image budget exceeded; remaining programs did not run";
92
166
 
93
- if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
167
+ if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
168
+ const text = programBatchText(this.results, this.programs.length, stopped);
94
169
 
95
- if (combined.aborted || performance.now() >= deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
170
+ if (text.length + this.imageTextChars() > this.config.maxReturnChars || result.details?.returnTruncated) stopped ||= "batch output budget exceeded; remaining programs did not run; earlier commits remain";
96
171
 
97
- if (stopped) break;
172
+ if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
173
+
174
+ if (this.combined.aborted || performance.now() >= this.deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
175
+
176
+ return stopped;
177
+ }
178
+
179
+ async runSequential() {
180
+ for (const [i, program] of this.programs.entries()) {
181
+ if (this.combined.aborted || performance.now() >= this.deadline) { this.stopped = "deadline or cancellation; remaining programs did not run"; break; }
182
+
183
+ const result = await this.runOne(program, i);
184
+ this.live[i] = [];
185
+ this.takeSettled(result, i);
186
+ this.stopped = this.sequentialStop(result, i);
187
+
188
+ if (this.stopped) break;
98
189
  }
99
- } finally { clearTimeout(timer); controller.abort(); }
100
-
101
- const bounded = truncateChars(programBatchText(results,programs.length,stopped),Math.max(0,config.maxReturnChars-imageTextChars()),"batch output");
102
- const content = [{type:"text",text:bounded.text}];
103
- images.forEach((image,i) => content.push({type:"text",text:imageLabels[i]},image));
104
-
105
- // Return a typed stop report instead of throwing away earlier results/images.
106
- // Single-program errors retain their existing throwing behavior.
107
- return {content,isError:!!stopped,details:{ok:!stopped,error:stopped || undefined,wallMs:Math.round(performance.now()-started),
108
- programs:results,attempted:results.length,total:programs.length,stopped,
109
- result:bounded.truncated ? bounded.text : results.map(result=>result.details?.result),
110
- returnTruncated:bounded.truncated || results.some(result=>result.details?.returnTruncated),
111
- logTruncated:results.some(result=>result.details?.logTruncated),logs:results.flatMap(result=>result.details?.logs ?? []),trace,mutations:mutationTotals(results)}};
190
+ }
191
+
192
+ failNote(failed) {
193
+ return this.stopped || (this.parallel && failed ? failed + " program" + (failed>1?"s":"") + " failed" : undefined);
194
+ }
195
+
196
+ boundedText(failed) {
197
+ const note = this.imageDropped && !this.stopped ? "some images dropped: batch image budget" : "";
198
+
199
+ return truncateChars(programBatchText(this.results,this.programs.length,this.stopped,this.parallel ? failed : 0) + (note ? "; " + note : ""),Math.max(0,this.config.maxReturnChars-this.imageTextChars()),"batch output");
200
+ }
201
+
202
+ finish() {
203
+ const failed = this.results.filter(result => result.details?.ok === false).length;
204
+ const bounded = this.boundedText(failed);
205
+ const content = [{type:"text",text:bounded.text}];
206
+ this.images.forEach((image,i) => content.push({type:"text",text:this.imageLabels[i]},image));
207
+
208
+ // Return a typed stop report instead of throwing away earlier results/images.
209
+ // Single-program errors retain their existing throwing behavior.
210
+ return {content,isError:!!this.stopped || (this.parallel && failed>0),details:{ok:!this.stopped && !(this.parallel && failed),error:this.failNote(failed),wallMs:Math.round(performance.now()-this.started),
211
+ programs:this.results,attempted:this.results.length,total:this.programs.length,stopped:this.stopped,parallel:this.parallel,
212
+ result:bounded.truncated ? bounded.text : this.results.map(result=>result.details?.result),
213
+ returnTruncated:bounded.truncated || this.results.some(result=>result.details?.returnTruncated),
214
+ logTruncated:this.results.some(result=>result.details?.logTruncated),logs:this.results.flatMap(result=>result.details?.logs ?? []),trace:this.trace,mutations:mutationTotals(this.results)}};
215
+ }
216
+
217
+ async run() {
218
+ try {
219
+ if (this.parallel) await this.runParallel();
220
+ else await this.runSequential();
221
+ } finally { clearTimeout(this.timer); this.controller.abort(); }
222
+
223
+ return this.finish();
224
+ }
225
+ }
226
+
227
+ /** Explicit known continuations, not inferred plans, retries, or a shared heap. */
228
+ export async function runProgramBatch(id, params, signal, onUpdate, ctx, config, execute) {
229
+ const programs = parseBatchPayload(params, config);
230
+
231
+ return new ProgramBatch(id, params, signal, onUpdate, ctx, config, execute, programs, batchTimeoutMs(params, config)).run();
112
232
  }
@@ -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,18 +1,19 @@
1
- // Complete model-facing API reference; kept in every request, not moved into history.
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.
1
+ // Standing tool description: sent on every request. One nova call, four commands inside.
2
+ export const REFERENCE = `JavaScript async body or arrow with read, write, edit, bash. file:path runs that program file instead. Put Markdown/scripts/argv in data. Guest has no fs/import/require.
3
3
 
4
- read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries
4
+ read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries[]
5
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
7
- read("agent://id?q=.answer") → JSON field from session artifacts
6
+ read({path,json:".field"}) → parsed JSON; .items[0:3], .items.length, quoted keys, or true; 16 MiB cap; no jq
7
+ raw JSON or selections over budget return {status:"too_large",path,keys} (length for arrays); narrow with json:".field" or slices
8
8
  read("symbol or question") → same view as resolve:true
9
- read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
9
+ read({query,resolve:true}) → {status,path,line,text,...}
10
10
  read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
11
- write(path, text) → replace; write({path,content,append:true}) → append without a prior read
12
- edit(path,oldText,newText) | edit({path,edits}) | edit({path,patch}) → numbered post-edit lines, checks, references
13
- edit(async () => {...}) → checkpoint: commit on success, rollback on throw; no shell, nesting, or outside commands
11
+ write(path, text) → replace an unread file; write({path,content,append:true}) → append without a prior read. After read use edit; replace:true overrides.
12
+ edit(path,oldText,newText) | edit({path,edits}) → numbered post-edit lines, checks, references
13
+ edit(async () => {...}) → checkpoint: commit on success, rollback on throw
14
14
  bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
15
- bash({command,args}) → literal argv, no shell expansion of args
15
+ bash({command,args}) → literal argv
16
16
 
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.`;
17
+ edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files. Array reads reject failures. Edits stage until success; bash commits preceding writes. Batch known reads, edits, and tests in this program (Promise.all, or programs with parallel:true).
18
+ programs:[{code|file,data?},...] sequential fresh guests, separate commits; top-level data defaults per entry. Stop on failure keeps earlier commits. parallel:true runs disjoint entries concurrently. Separate supernova calls only when the next step needs a model decision.
19
+ `;