pi-supernova 0.6.0 → 0.7.1

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 (49) hide show
  1. package/README.md +27 -3
  2. package/docs/CHANGELOG.md +114 -0
  3. package/docs/TOKEN_COSTS.md +13 -5
  4. package/index.js +120 -79
  5. package/package.json +1 -1
  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 +28 -222
  15. package/src/bridge/host-bridge.js +113 -1668
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -198
  18. package/src/context/evidence.js +140 -76
  19. package/src/context/fuzzy.js +42 -24
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +23 -18
  22. package/src/context/repo-index.js +206 -170
  23. package/src/context/search.js +157 -77
  24. package/src/context/snap.js +240 -136
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +14 -5
  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 +12 -8
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +94 -50
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +205 -175
  37. package/src/fs/workspace.js +119 -108
  38. package/src/output/bottleneck.js +195 -116
  39. package/src/output/format.js +101 -67
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +296 -292
  42. package/src/runtime/parallel.js +97 -64
  43. package/src/runtime/program-batch.js +178 -69
  44. package/src/runtime/reference.js +15 -14
  45. package/src/runtime/runtime.js +327 -187
  46. package/src/shared/decode.js +58 -36
  47. package/src/ui/omp-frame.js +59 -42
  48. package/src/ui/render-measure.js +51 -29
  49. package/src/ui/render.js +241 -145
@@ -4,33 +4,91 @@ const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "
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) {
8
- if (!isObject(args)) args = {};
9
- name = String(name);
7
+ const NATIVE_TOOLS = ["read", "edit", "write", "bash"];
10
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) {
11
15
  if ((config.mutatingTools ?? []).includes(name)) return true;
12
16
 
13
17
  if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
14
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
+
15
40
  if (definition?.annotations?.readOnlyHint === true) return false;
16
41
 
17
- if (name === "lsp") {
18
- const action = args.action ?? args.operation;
42
+ if (name === "lsp") return lspMutates(args);
43
+ const special = SPECIAL_MUTATION[name];
44
+
45
+ if (special) return special(args);
19
46
 
20
- if (READ_ONLY_LSP.has(action)) return false;
47
+ return !READ_ONLY_TOOLS.has(name);
48
+ }
49
+
50
+ function takeScheduledWave(queue, first, maxParallelReads) {
51
+ const wave = [first];
21
52
 
22
- if (["rename", "rename_file"].includes(action)) return args.apply !== false;
53
+ if (first.name !== "read") return wave;
23
54
 
24
- if (action === "code_actions") return args.apply === true;
55
+ while (wave.length < maxParallelReads && queue[0]?.name === "read") {
56
+ const next = queue.shift();
25
57
 
26
- return true;
58
+ if (!next.cancelled) wave.push(next);
27
59
  }
28
60
 
29
- if (name === "todo") return args.op !== "view";
61
+ return wave;
62
+ }
30
63
 
31
- 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
+ }
32
76
 
33
- 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 });
34
92
  }
35
93
 
36
94
  /** FIFO read waves with mutation barriers; callers keep independent promises. */
@@ -46,33 +104,14 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
46
104
  const first = queue.shift();
47
105
 
48
106
  if (first.cancelled) continue;
49
- const wave = [first];
107
+ const wave = takeScheduledWave(queue, first, maxParallelReads);
50
108
 
51
109
  if (first.name === "read") {
52
- while (wave.length < maxParallelReads && queue[0]?.name === "read") {
53
- const next = queue.shift();
54
-
55
- if (!next.cancelled) wave.push(next);
56
- }
57
-
58
110
  stats.readWaves++;
59
111
  stats.peakParallelReads = Math.max(stats.peakParallelReads, wave.length);
60
112
  }
61
113
 
62
- // Settle each promise separately: one failed read must not discard its
63
- // siblings or release a write barrier while other reads are still active.
64
- await Promise.all(wave.map(async 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
- }));
114
+ await runScheduledWave(wave);
76
115
  }
77
116
  } finally { draining = false; }
78
117
  }
@@ -80,7 +119,7 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
80
119
  return {
81
120
  stats,
82
121
  schedule(name, run, signal) {
83
- if (!["read", "edit", "write", "bash"].includes(name) || !isFunction(run)) {
122
+ if (!NATIVE_TOOLS.includes(name) || !isFunction(run)) {
84
123
  return Promise.reject(new Error("scheduler requires a native tool and an executor"));
85
124
  }
86
125
 
@@ -89,14 +128,7 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
89
128
 
90
129
  return new Promise((resolve, reject) => {
91
130
  const job = { name, run, signal, resolve, reject, started: false, cancelled: false };
92
- job.abort = () => {
93
- if (job.started) return;
94
- job.cancelled = true;
95
- signal.removeEventListener("abort", job.abort);
96
- reject(signal.reason ?? new Error("aborted"));
97
- };
98
-
99
- signal?.addEventListener("abort", job.abort, { once: true });
131
+ attachJobAbort(job, signal, reject);
100
132
  queue.push(job);
101
133
 
102
134
  if (!draining) {
@@ -116,26 +148,27 @@ function requireArray(value, name) {
116
148
  return value;
117
149
  }
118
150
 
119
- export async function runParallelWave(thunks, meta, options = {}) {
120
- const list = requireArray(thunks, "parallel wave");
151
+ function waveMutates(list, meta, config) {
152
+ const names = meta?.names ?? [];
121
153
 
122
- 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
+ }
123
156
 
124
- if (!list.length) return { results: [], mode: "serial", reason: "empty" };
125
- const { mode = "auto", config = {} } = options;
126
- const names = meta?.names ?? [];
127
- 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
+ }
128
160
 
129
- if (!mutating && (mode === "parallel" || (mode === "auto" && list.length > 1))) {
130
- // Do not finish a wave while its already-started host calls are still running.
131
- const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
132
- 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");
133
165
 
134
- if (failure) throw failure.reason;
166
+ if (failure) throw failure.reason;
135
167
 
136
- return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
137
- }
168
+ return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
169
+ }
138
170
 
171
+ async function runSerial(list, mutating) {
139
172
  const results = [];
140
173
 
141
174
  for (const thunk of list) results.push(await thunk());
@@ -143,16 +176,16 @@ export async function runParallelWave(thunks, meta, options = {}) {
143
176
  return { results, mode: "serial", reason: mutating ? "mutating" : "single-or-forced" };
144
177
  }
145
178
 
146
- export async function parallel(items) {
147
- return Promise.all(requireArray(items, "parallel").map(item => isFunction(item) ? item() : item));
148
- }
179
+ export async function runParallelWave(thunks, meta, options = {}) {
180
+ const list = requireArray(thunks, "parallel wave");
149
181
 
150
- export async function pipeline(items, ...stages) {
151
- let current = requireArray(items, "pipeline");
182
+ if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
152
183
 
153
- 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);
154
187
 
155
- for (const stage of stages) current = await Promise.all(current.map(item => stage(item)));
188
+ if (shouldRunParallel(mutating, mode, list.length)) return settleParallel(list);
156
189
 
157
- return current;
190
+ return runSerial(list, mutating);
158
191
  }
@@ -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,27 +28,23 @@ export function programBatchText(results, total, stopped = "") {
23
28
  }).join("");
24
29
  }
25
30
 
26
- function batchInputs(params, config) {
27
- if (["code","file"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code or file; no programs ran");
28
-
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) {
38
39
  const hasDefault = params.data !== undefined;
39
40
  let encoded;
40
41
 
41
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"); }
42
43
 
43
- if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
44
-
45
- const parsed = JSON.parse(encoded);
44
+ return { hasDefault, encoded };
45
+ }
46
46
 
47
+ function applyDefaultData(parsed, hasDefault) {
47
48
  if (!hasDefault) return parsed;
48
49
  if (!Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
49
50
 
@@ -52,72 +53,180 @@ function batchInputs(params, config) {
52
53
  return parsed.programs.map(program => program.data === undefined ? {...program,data:parsed.data} : program);
53
54
  }
54
55
 
55
- /** Explicit known continuations, not inferred plans, retries, or a shared heap. */
56
- export async function runProgramBatch(id, params, signal, onUpdate, ctx, config, execute) {
57
- const programs = batchInputs(params,config);
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);
63
+
64
+ if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
65
+
66
+ return applyDefaultData(JSON.parse(encoded), hasDefault);
67
+ }
68
+
69
+ function batchTimeoutMs(params, config) {
58
70
  const requestedTimeout = params.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
59
71
 
60
72
  if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("program batch timeoutMs must be a positive finite number");
61
- const started = performance.now();
62
- const timeout = requestedTimeout;
63
- const deadline = started + timeout;
64
- const controller = new AbortController();
65
- const combined = signal ? AbortSignal.any([signal,controller.signal]) : controller.signal;
66
- const timer = setTimeout(() => controller.abort(),Math.min(timeout,2147483647));
67
- const budget = {calls:0,logLines:0};
68
- const results = [], images = [], imageLabels = [], trace = [];
69
- const imageTextChars = () => imageLabels.reduce((chars,label)=>chars+label.length+1,0);
70
- let imageBytes = 0, stopped = "";
71
-
72
- try {
73
- for (const [i, program] of programs.entries()) {
74
- if (combined.aborted || performance.now() >= deadline) { stopped = "deadline or cancellation; remaining programs did not run"; break; }
75
-
76
- let result;
77
-
78
- try {
79
- result = await execute(id + ":" + i,{...program,timeoutMs:Math.max(1,Math.ceil(deadline-performance.now()))},combined,update => {
80
- try { onUpdate?.({...update,details:{...update.details,trace:[...trace,...(update.details?.trace ?? [])]}}); } catch {}
81
- },ctx,budget);
82
- } catch (error) {
83
- result = error.supernovaResult ?? {content:[{type:"text",text:String(error.message ?? error)}],details:{ok:false,error:String(error.message ?? error)}};
84
- }
85
73
 
86
- results.push(result);
87
- trace.push(...(result.details?.trace ?? []));
88
- let image = 0;
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
+ }
89
123
 
90
- for (const block of Array.isArray(result?.content) ? result.content : []) if (block?.type === "image" && isString(block.data)) {
91
- imageBytes += Buffer.byteLength(block.data,"base64");
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");
92
127
 
93
- if (images.length >= 16 || imageBytes > 20*1024*1024) { stopped = "batch image budget exceeded; remaining programs did not run"; break; }
128
+ if (this.images.length >= 16 || this.imageBytes > 20*1024*1024) { this.imageDropped = true; continue; }
94
129
 
95
- images.push(block);
96
- imageLabels.push("program " + (i+1) + " image " + (++image));
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
+ }
140
+
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;
145
+
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] = [];
97
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
+ }
161
+
162
+ sequentialStop(result, i) {
163
+ let stopped = this.stopped;
164
+
165
+ if (this.imageDropped) stopped = "batch image budget exceeded; remaining programs did not run";
166
+
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);
98
169
 
99
- if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
100
- const text = programBatchText(results,programs.length,stopped);
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";
101
171
 
102
- if (text.length + imageTextChars() > config.maxReturnChars || result.details?.returnTruncated) stopped ||= "batch output budget exceeded; remaining programs did not run; earlier commits remain";
172
+ if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
103
173
 
104
- if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
174
+ if (this.combined.aborted || performance.now() >= this.deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
105
175
 
106
- if (combined.aborted || performance.now() >= deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
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; }
107
182
 
108
- if (stopped) break;
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;
109
189
  }
110
- } finally { clearTimeout(timer); controller.abort(); }
111
-
112
- const bounded = truncateChars(programBatchText(results,programs.length,stopped),Math.max(0,config.maxReturnChars-imageTextChars()),"batch output");
113
- const content = [{type:"text",text:bounded.text}];
114
- images.forEach((image,i) => content.push({type:"text",text:imageLabels[i]},image));
115
-
116
- // Return a typed stop report instead of throwing away earlier results/images.
117
- // Single-program errors retain their existing throwing behavior.
118
- return {content,isError:!!stopped,details:{ok:!stopped,error:stopped || undefined,wallMs:Math.round(performance.now()-started),
119
- programs:results,attempted:results.length,total:programs.length,stopped,
120
- result:bounded.truncated ? bounded.text : results.map(result=>result.details?.result),
121
- returnTruncated:bounded.truncated || results.some(result=>result.details?.returnTruncated),
122
- 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();
123
232
  }
@@ -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[]; up to 64 paths
5
- read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP); 20 MiB max, 16 attachments max
6
- read({path,json:".field"}) → parsed JSON; .items[0:3], quoted keys, 1-64 selectors, or true; 16 MiB cap; no jq
7
- read("agent://id?q=.answer") → JSON field from session artifacts
8
- read("symbol or question") → same view as resolve:true; source questions use at most 16 keywords
9
- read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
4
+ read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries[]
5
+ read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP)
6
+ read({path,json:".field"}) → parsed JSON; .items[0:3], .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
+ read("symbol or question") → same view as resolve:true
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; top-level data defaults per entry, explicit entry data replaces it. Stop on failure keeps earlier commits. Separate calls when the next step needs a model decision.`;
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
+ `;