pi-supernova 0.8.1 → 0.9.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 (66) hide show
  1. package/README.md +244 -54
  2. package/docs/CHANGELOG.md +111 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +23 -33
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/query.js +71 -0
  21. package/src/context/repo-index.js +8 -162
  22. package/src/context/search-files.js +19 -0
  23. package/src/context/search.js +2 -24
  24. package/src/context/snap-search.js +202 -0
  25. package/src/context/snap.js +5 -266
  26. package/src/context/source-entry.js +112 -0
  27. package/src/contract/bash.js +18 -2
  28. package/src/contract/program.js +36 -0
  29. package/src/contract/read.js +8 -53
  30. package/src/fs/check.js +1 -1
  31. package/src/fs/commit.js +161 -0
  32. package/src/fs/diff.js +11 -15
  33. package/src/fs/directory.js +79 -0
  34. package/src/fs/file-io.js +100 -0
  35. package/src/fs/glob.js +54 -0
  36. package/src/fs/json-size.js +54 -0
  37. package/src/fs/lines.js +117 -0
  38. package/src/fs/read-window.js +74 -0
  39. package/src/fs/session-resource.js +50 -0
  40. package/src/fs/text-ops.js +7 -242
  41. package/src/fs/vfs.js +5 -239
  42. package/src/fs/workspace.js +7 -2
  43. package/src/output/bottleneck.js +13 -67
  44. package/src/output/final.js +114 -0
  45. package/src/output/format.js +94 -5
  46. package/src/output/outcome.js +91 -0
  47. package/src/runtime/batch-input.js +68 -0
  48. package/src/runtime/guest-api.js +281 -0
  49. package/src/runtime/guest-worker.js +62 -333
  50. package/src/runtime/parallel.js +41 -39
  51. package/src/runtime/program-batch.js +35 -68
  52. package/src/runtime/program-file.js +3 -11
  53. package/src/runtime/program.js +141 -0
  54. package/src/runtime/reference.js +11 -10
  55. package/src/runtime/runtime.js +94 -261
  56. package/src/runtime/worker-pool.js +91 -0
  57. package/src/shared/decode.js +22 -8
  58. package/src/shared/image-worker.js +30 -0
  59. package/src/shared/image.js +78 -0
  60. package/src/shared/png.js +57 -0
  61. package/src/shared/result.js +77 -0
  62. package/src/shared/syntax-context.js +61 -3
  63. package/src/ui/host-render.js +104 -0
  64. package/src/ui/progress.js +51 -0
  65. package/src/ui/render.js +21 -421
  66. package/src/ui/trace.js +277 -0
@@ -1,4 +1,5 @@
1
- import { isObject, isString } from "../shared/decode.js";
1
+ import {parseBatchPayload,batchTimeoutMs} from "./batch-input.js";
2
+ import { isString } from "../shared/decode.js";
2
3
  import { truncateChars } from "../output/format.js";
3
4
 
4
5
  const textOf = result => (Array.isArray(result?.content) ? result.content : []).filter(block => block?.type === "text").map(block => block.text).join("\n");
@@ -28,66 +29,6 @@ export function programBatchText(results, total, stopped = "", failed = 0) {
28
29
  }).join("");
29
30
  }
30
31
 
31
- function assertProgramEntry(p, defaults = {}) {
32
- const source = p?.code === undefined && p?.file === undefined ? defaults : p;
33
-
34
- if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
35
- ((source.code === undefined) === (source.file === undefined)) || !isString(source.code ?? source.file) || !(source.code ?? source.file).trim()) {
36
- throw new Error("each program requires code OR file (own or shared), with optional data; no nested batches or per-entry timeouts; no programs ran");
37
- }
38
- }
39
-
40
- const objectData = value => isObject(value) && !Array.isArray(value);
41
-
42
- function applyBatchDefaults(parsed, mergeData) {
43
- if (mergeData && !objectData(parsed.data)) throw new Error("mergeData requires top-level object data; no programs ran");
44
- const source = parsed.code !== undefined ? {code:parsed.code} : parsed.file !== undefined ? {file:parsed.file} : {};
45
-
46
- return parsed.programs.map(program => {
47
- assertProgramEntry(program, source);
48
- const entry = program.code === undefined && program.file === undefined ? {...source,...program} : program;
49
-
50
- if (mergeData) {
51
- if (program.data !== undefined && !objectData(program.data)) throw new Error("mergeData requires object data in every explicit entry; no programs ran");
52
- // Shallow own-property overlay, including literal __proto__ keys. The
53
- // runtime snapshots data again per guest; no mutable heap is shared.
54
- entry.data = {...parsed.data,...program.data};
55
- } else if (program.data === undefined && Object.hasOwn(parsed,"data")) entry.data = parsed.data;
56
-
57
- return entry;
58
- });
59
- }
60
-
61
- function parseBatchPayload(params, config) {
62
- if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
63
- if (params.mergeData !== undefined && params.mergeData !== true && params.mergeData !== false) throw new Error("mergeData must be boolean; no programs ran");
64
- const defaults = Object.fromEntries(["code","file","data"].filter(key => params[key] !== undefined).map(key => [key,params[key]]));
65
-
66
- if (defaults.code !== undefined || defaults.file !== undefined) assertProgramEntry(defaults);
67
- for (const p of params.programs) assertProgramEntry(p, defaults);
68
- const hasDefaults = Object.keys(defaults).length > 0;
69
- let encoded;
70
-
71
- try { encoded = JSON.stringify(hasDefaults ? {programs:params.programs,...defaults} : params.programs); } catch { throw new Error("programs and defaults must be JSON-serializable; no programs ran"); }
72
-
73
- if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget (including shared code/file/data); no programs ran");
74
- const parsed = hasDefaults ? JSON.parse(encoded) : {programs:JSON.parse(encoded)};
75
-
76
- if (Object.hasOwn(defaults,"data") && !Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
77
-
78
- // Validate and expand every entry before executing any. Defaults count once
79
- // against admission, not once for each independent guest receiving a copy.
80
- return applyBatchDefaults(parsed, params.mergeData === true);
81
- }
82
-
83
- function batchTimeoutMs(params, config) {
84
- const requestedTimeout = params.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
85
-
86
- if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("program batch timeoutMs must be a positive finite number");
87
-
88
- return requestedTimeout;
89
- }
90
-
91
32
  const MAX_PARALLEL_PROGRAMS = 8;
92
33
 
93
34
  class ProgramBatch {
@@ -136,7 +77,7 @@ class ProgramBatch {
136
77
  }
137
78
 
138
79
  collectImages(result, i) {
139
- for (const block of Array.isArray(result?.content) ? result.content : []) if (block?.type === "image" && isString(block.data)) {
80
+ for (const block of imageBlocks(result)) {
140
81
  this.imageBytes += Buffer.byteLength(block.data,"base64");
141
82
 
142
83
  if (this.images.length >= 16 || this.imageBytes > 20*1024*1024) { this.imageDropped = true; continue; }
@@ -156,16 +97,26 @@ class ProgramBatch {
156
97
  this.collectImages(result, i);
157
98
  }
158
99
 
100
+ parallelBudgetStop(settled) {
101
+ const results = settled.filter(Boolean);
102
+ const {images, bytes} = imageTotals(settled);
103
+ let kind;
104
+ if (images > 16 || bytes > 20 * 1024 * 1024) kind = "image";
105
+ else if (results.some(result => result.details?.logTruncated) || results.reduce((n, result) => n + (result.details?.logs?.length ?? 0), 0) > (this.config.maxLogLines ?? 100)) kind = "log";
106
+ return kind ? "batch " + kind + " budget exceeded; completed commits remain" : "";
107
+ }
108
+
159
109
  async runParallel() {
160
110
  const limit = Math.min(this.programs.length, MAX_PARALLEL_PROGRAMS);
161
111
  const settled = Array.from({ length: this.programs.length });
162
112
  let next = 0;
163
113
 
164
114
  await Promise.all(Array.from({length: limit}, async () => {
165
- while (next < this.programs.length && !this.combined.aborted && performance.now() < this.deadline) {
115
+ while (next < this.programs.length && !this.stopped && !this.combined.aborted && performance.now() < this.deadline) {
166
116
  const i = next++;
167
117
  settled[i] = await this.runOne(this.programs[i], i);
168
118
  this.live[i] = [];
119
+ this.stopped ||= this.parallelBudgetStop(settled);
169
120
  }
170
121
  }));
171
122
 
@@ -174,7 +125,7 @@ class ProgramBatch {
174
125
  this.takeSettled(settled[i], i);
175
126
  }
176
127
 
177
- if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
128
+ if ((!this.stopped && settled.includes(undefined)) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
178
129
  }
179
130
 
180
131
  sequentialStop(result, i) {
@@ -183,9 +134,8 @@ class ProgramBatch {
183
134
  if (this.imageDropped) stopped = "batch image budget exceeded; remaining programs did not run";
184
135
 
185
136
  if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
186
- const text = programBatchText(this.results, this.programs.length, stopped);
187
-
188
- if (text.length + this.imageTextChars() > this.config.maxReturnChars || result.details?.returnTruncated) stopped ||= "batch output budget exceeded; remaining programs did not run; earlier commits remain";
137
+ // Display clipping is not an execution failure. Finish every requested entry
138
+ // unless a real execution/resource limit stops it; boundedText caps delivery.
189
139
 
190
140
  if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
191
141
 
@@ -222,6 +172,8 @@ class ProgramBatch {
222
172
  const failed = this.results.filter(result => result.details?.ok === false).length;
223
173
  const bounded = this.boundedText(failed);
224
174
  const content = [{type:"text",text:bounded.text}];
175
+ const logs = this.results.flatMap(result => result.details?.logs ?? []);
176
+ const logLimit = this.config.maxLogLines ?? 100;
225
177
  this.images.forEach((image,i) => content.push({type:"text",text:this.imageLabels[i]},image));
226
178
 
227
179
  // Return a typed stop report instead of throwing away earlier results/images.
@@ -230,7 +182,7 @@ class ProgramBatch {
230
182
  programs:this.results,attempted:this.results.length,total:this.programs.length,stopped:this.stopped,parallel:this.parallel,
231
183
  result:bounded.truncated ? bounded.text : this.results.map(result=>result.details?.result),
232
184
  returnTruncated:bounded.truncated || this.results.some(result=>result.details?.returnTruncated),
233
- logTruncated:this.results.some(result=>result.details?.logTruncated),logs:this.results.flatMap(result=>result.details?.logs ?? []),trace:this.trace,mutations:mutationTotals(this.results)}};
185
+ logTruncated:logs.length > logLimit || this.results.some(result=>result.details?.logTruncated),logs:logs.slice(0,logLimit),trace:this.trace,mutations:mutationTotals(this.results)}};
234
186
  }
235
187
 
236
188
  async run() {
@@ -249,3 +201,18 @@ export async function runProgramBatch(id, params, signal, onUpdate, ctx, config,
249
201
 
250
202
  return new ProgramBatch(id, params, signal, onUpdate, ctx, config, execute, programs, batchTimeoutMs(params, config)).run();
251
203
  }
204
+
205
+ function imageBlocks(result) {
206
+ return (Array.isArray(result?.content) ? result.content : []).filter(block => block?.type === "image" && isString(block.data));
207
+ }
208
+
209
+ function imageTotals(settled) {
210
+ let images = 0, bytes = 0;
211
+ for (const result of settled) {
212
+ for (const block of imageBlocks(result)) {
213
+ images++;
214
+ bytes += Buffer.byteLength(block.data, "base64");
215
+ }
216
+ }
217
+ return {images, bytes};
218
+ }
@@ -1,3 +1,4 @@
1
+ import { readLimitedBytes } from "../fs/file-io.js";
1
2
  import * as fs from "node:fs/promises";
2
3
  import { resolveWorkspacePath } from "../fs/workspace.js";
3
4
 
@@ -21,22 +22,13 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
21
22
  const maxBytes = chars * 3;
22
23
  const tooLarge = () => new Error("code exceeds " + chars + " characters; split the program");
23
24
 
24
- if (stat.size > maxBytes) throw tooLarge();
25
- const chunks = [];
26
- let bytes = 0;
27
-
28
- for await (const chunk of handle.createReadStream({ end: maxBytes, autoClose: false, signal })) {
29
- bytes += chunk.length;
30
-
31
- if (bytes > maxBytes) throw tooLarge();
32
- chunks.push(chunk);
33
- }
25
+ const bytes = await readLimitedBytes(handle, stat, maxBytes, file, signal, tooLarge);
34
26
 
35
27
  signal?.throwIfAborted();
36
28
  // Do not silently replace invalid bytes in executable source. Preserve BOMs.
37
29
  let code;
38
30
 
39
- try { code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); }
31
+ try { code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
40
32
  catch { throw new Error("program file " + file + " is not valid UTF-8 (encoded data could not be decoded); save it as UTF-8 text"); }
41
33
 
42
34
  if (code.length > chars) throw tooLarge();
@@ -0,0 +1,141 @@
1
+ import {parse} from 'acorn';
2
+ import {isObject,isString} from '../shared/decode.js';
3
+ import {guestImportMessage,isDeniedGuestImport} from './guest-deny-imports.js';
4
+
5
+ const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
6
+
7
+ const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
8
+
9
+ function hasReturn(node) {
10
+ if (!isObject(node)) return false;
11
+
12
+ if (node.type === "ReturnStatement") return true;
13
+
14
+ if (FUNCTION_TYPES.has(node.type)) return false;
15
+
16
+ return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
17
+ }
18
+
19
+ function parseExpressionFunction(code) {
20
+ try {
21
+ const program = parse(code, PARSE_OPTIONS);
22
+ const statements = program.body.filter(node => node.type !== "EmptyStatement");
23
+ const statement = statements.length === 1 ? statements[0] : undefined;
24
+ const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
25
+
26
+ if (candidate && FUNCTION_TYPES.has(candidate.type)) {
27
+ return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
28
+ }
29
+
30
+ return { program };
31
+ } catch (bodyError) {
32
+ const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
33
+
34
+ try {
35
+ const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
36
+ const expression = wrapped.body[0]?.expression;
37
+
38
+ if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
39
+
40
+ return { expression, expressionSource };
41
+ } catch { throw bodyError; }
42
+ }
43
+ }
44
+
45
+ function deniedSpecifier(node) {
46
+ if (node?.type === "Literal" && isString(node.value)) return node.value;
47
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
48
+ }
49
+
50
+ function assertGuestImports(node) {
51
+ if (Array.isArray(node)) { node.forEach(assertGuestImports); return; }
52
+ if (!isObject(node)) return;
53
+ if (["ImportDeclaration", "ImportExpression"].includes(node.type)) rejectGuestImport(node.source);
54
+ if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") rejectGuestImport(node.arguments?.[0]);
55
+ for (const key of Object.keys(node)) {
56
+ if (!["start", "end", "loc", "range"].includes(key)) assertGuestImports(node[key]);
57
+ }
58
+ }
59
+
60
+ function prepareProgram(code) {
61
+ const parsed = parseExpressionFunction(code);
62
+ assertGuestImports(parsed.program ?? parsed.expression);
63
+ const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
64
+ const returns = parsed.expression
65
+ ? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
66
+ : hasReturn(parsed.program);
67
+
68
+ return { body, hasReturn: returns };
69
+ }
70
+
71
+ function admitData(data, cap) {
72
+ if (data === undefined) return { data };
73
+
74
+ try {
75
+ const encoded = JSON.stringify(data);
76
+
77
+ if (encoded === undefined) return { error: "data must be JSON-serializable" };
78
+ if (encoded.length > cap) return { error: "data exceeds " + cap + " characters (serialized JSON: " + encoded.length + " UTF-16 characters); no commands ran. Split literal inputs across invocations; large text can use write({path,content,append:true}) chunks without omitting content" };
79
+
80
+ return { data: JSON.parse(encoded) };
81
+ } catch { return { error: "data must be JSON-serializable" }; }
82
+ }
83
+
84
+ function admitCode({ code, file, cap }) {
85
+ if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
86
+ if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
87
+ if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
88
+ }
89
+
90
+ function admitTimeout(config) {
91
+ const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
92
+
93
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
94
+
95
+ return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
96
+ }
97
+
98
+ function admitGuest({ code, file, data, config }) {
99
+ const cap = config.maxCodeChars ?? 48000;
100
+ const codeError = admitCode({ code, file, cap });
101
+
102
+ if (codeError) return codeError;
103
+ const admitted = admitData(data, cap);
104
+
105
+ if (admitted.error) return admitted;
106
+ const timeout = admitTimeout(config);
107
+
108
+ if (timeout.error) return timeout;
109
+
110
+ return { data: admitted.data, timeoutMs: timeout.timeoutMs };
111
+ }
112
+ export { admitGuest, prepareProgram };
113
+
114
+ function rejectGuestImport(source) {
115
+ const spec = deniedSpecifier(source);
116
+ const reason = spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash";
117
+ throw new Error(reason + "; no commands ran");
118
+ }
119
+
120
+ function containingAwait(node, offset) {
121
+ if (Array.isArray(node)) return node.map(child => containingAwait(child, offset)).find(Boolean);
122
+ if (!isObject(node) || offset < node.start || offset >= node.end) return null;
123
+ for (const child of Object.values(node)) {
124
+ const found = containingAwait(child, offset);
125
+ if (found) return found;
126
+ }
127
+ return node.type === "AwaitExpression" ? node : null;
128
+ }
129
+
130
+ // V8 points at 'await'; JSC points at the called function's parenthesis. Report
131
+ // the enclosing await expression consistently, using source syntax, not offsets.
132
+ export function normalizeGuestLocation(source, location) {
133
+ if (!location?.awaited) return location;
134
+ const {line, col} = location;
135
+ const prefix = source.split("\n").slice(0, line - 1).join("\n");
136
+ const offset = prefix.length + Number(line > 1) + col - 1;
137
+ try {
138
+ const node = containingAwait(parse(source, {...PARSE_OPTIONS, locations:true}), offset);
139
+ return node ? {line:node.loc.start.line, col:node.loc.start.column + 1} : location;
140
+ } catch { return location; }
141
+ }
@@ -1,15 +1,16 @@
1
1
  // Standing tool description: sent on every request. No result or history compression.
2
- export const REFERENCE = `JS body/async arrow with read/write/edit/bash; no fs/import/require. file runs workspace scripts. data holds literal text/scripts/argv (≤48000 serialized JSON chars).
3
- read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 attachments/20 MiB total).
4
- Path-only: ≤160 lines AND 8192 characters (UTF-16). Use read(path,{offset:1,limit:80}), about, or complete:true (whole file ≤31744 chars). Large JSONL: bounded parser via bash.
5
- read({path,json:selector}) → parsed JSON; selectors ".field", ".a[0:3]", ".a.length", quoted keys, true; 16 MiB input, no jq. Oversized → {status:"too_large",keys} (arrays: length); narrow selectors.
2
+ export const REFERENCE = `JS body/async arrow: read/write/edit/bash; no fs/import/require. file: workspace scripts; data: literals (≤48000 JSON chars).
3
+ read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 images/20 MiB).
4
+ Text ≤64 MiB internally; complete:true requires the whole file. Display alone is capped; return a summary or read(path,{offset:1,limit:80}). Larger files/JSONL: bounded bash parser.
5
+ read({path,json:selector}) → parsed JSON: ".field", ".a[0:3]", ".a.length", quoted keys, true; input ≤16 MiB, selections ≤64 MiB storage, no jq. Values retain their types.
6
6
  read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span, not the file.
7
7
  read(path,{about}) → windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations.
8
8
  write(path,text) replaces unread workspace files; write({path,content,append:true}) appends without reading. After read: edit or replace:true.
9
- edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) exact unique read text; numbered windows (including misses), checks/references.
10
- edit(view,text) replaces the span; edit(view,old,new) matches uniquely within it. edit(async()=>{...}) checkpoint merges on success, rolls back/rethrows on failure; catch to recover.
11
- bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv; bounded output, nonzero throws; inherits program timeout unless overridden.
12
- Edits stage until success; bash commits first. Array errors abort; Promise.allSettled for optional reads.
13
- programs:[{code?,file?,data?}] inherits top-level code OR file and data. Entry source overrides; data replaces unless mergeData:true (shallow objects, entry keys win).
14
- Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known work; separate calls only for new decisions.
9
+ edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) unique exact read text; returns numbered windows/checks/references.
10
+ edit(view,text) replaces span; edit(view,old,new) uniquely matches within it. edit(async()=>{...}) checkpoint: merge on success, rollback/rethrow on failure.
11
+ bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv for scripts; bounded output, nonzero throws. Outer timeoutMs caps ALL waits/commands; bash inherits unless overridden.
12
+ Edits commit on success/before bash. Promise.allSettled keeps partial results.
13
+ programs:[{code?,file?,data?}] inherits source/data; entries override. mergeData:true shallow object merge (entry keys win).
14
+ Promise.all: ≤8 reads or disjoint-file mutations; same-file serial; bash/checkpoint barriers.
15
+ Fresh guests/separate commits. Sequential failure stops; prior commits stay. parallel:true for disjoint entries. ONE call for known work; split for new decisions.
15
16
  `;