pi-supernova 0.7.1 → 0.8.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.
@@ -1,4 +1,4 @@
1
- import { isString, isObject } from "../shared/decode.js";
1
+ import { isString, isObject, mapChangedChildren } from "../shared/decode.js";
2
2
 
3
3
  function normalizeText(text) {
4
4
  return isString(text) ? text : String(text ?? "");
@@ -109,33 +109,41 @@ function formatRawStringArray(value) {
109
109
  return raw.length < escapedSize ? raw : null;
110
110
  }
111
111
 
112
- function shouldFrameRaw(input) {
113
- return isString(input) && input.includes("\n") && !hasUnpairedSurrogate(input) && JSON.stringify(input).length - input.length > 64;
112
+ function rawStringSize(input) {
113
+ if (!isString(input) || !input.includes("\n") || hasUnpairedSurrogate(input)) return 0;
114
+ const size = JSON.stringify(input).length;
115
+
116
+ return size - input.length > 64 ? size : 0;
114
117
  }
115
118
 
116
- function visitRawStrings(input, strings) {
117
- if (shouldFrameRaw(input)) {
118
- const index = strings.push(input) - 1;
119
+ function visitRawStrings(input, acc) {
120
+ const size = rawStringSize(input);
121
+
122
+ if (size) {
123
+ const index = acc.strings.push(input) - 1;
124
+ acc.escapedChars += size;
119
125
 
120
126
  return { [RAW_TEXT]: "raw[" + index + "]" };
121
127
  }
122
128
 
123
- if (Array.isArray(input)) return input.map(child => visitRawStrings(child, strings));
124
-
125
- if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visitRawStrings(child, strings)]));
126
-
127
- return input;
129
+ return mapChangedChildren(input, visitRawStrings, acc);
128
130
  }
129
131
 
130
- function framedReturn(value, escaped) {
131
- const strings = [];
132
- const referencedValue = visitRawStrings(value, strings);
132
+ function framedReturn(value) {
133
+ const acc = { strings: [], escapedChars: 0 };
134
+ const referencedValue = visitRawStrings(value, acc);
135
+ const { strings } = acc;
133
136
 
134
- if (!strings.length) return escaped;
137
+ if (!strings.length) return formatValue(value);
135
138
  // Keep every key, value, duplicate string and byte. References are unquoted
136
139
  // expressions, so literal "raw[0]" values and header-like source cannot collide.
137
140
  const framed = formatValue(referencedValue) + "\nraw strings[" + strings.length + "]\n" + strings.map((text, i) => "raw[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
138
141
 
142
+ // The ordinary rendering contains at least these complete escaped literals.
143
+ // If framing beats even that lower bound, do not build the discarded rendering.
144
+ if (framed.length < acc.escapedChars) return framed;
145
+ const escaped = formatValue(value);
146
+
139
147
  return framed.length < escaped.length ? framed : escaped;
140
148
  }
141
149
 
@@ -146,7 +154,7 @@ export function formatReturn(value) {
146
154
 
147
155
  if (rawArray !== null) return rawArray;
148
156
 
149
- return framedReturn(value, formatValue(value));
157
+ return framedReturn(value);
150
158
  }
151
159
 
152
160
  const RAW_TEXT = Symbol("raw text reference");
@@ -242,7 +242,7 @@ async function runSpeculation(fn, token, checkpointScope, drainReads, enqueueHos
242
242
 
243
243
  if (began) await enqueueHost(() => rpc("speculateRollback", []));
244
244
 
245
- return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
245
+ throw err;
246
246
  }
247
247
  }
248
248
 
@@ -28,42 +28,56 @@ export function programBatchText(results, total, stopped = "", failed = 0) {
28
28
  }).join("");
29
29
  }
30
30
 
31
- function assertProgramEntry(p) {
31
+ function assertProgramEntry(p, defaults = {}) {
32
+ const source = p?.code === undefined && p?.file === undefined ? defaults : p;
33
+
32
34
  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
+ ((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");
35
37
  }
36
38
  }
37
39
 
38
- function encodeBatchPayload(params) {
39
- const hasDefault = params.data !== undefined;
40
- let encoded;
40
+ const objectData = value => isObject(value) && !Array.isArray(value);
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
+ 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} : {};
43
45
 
44
- return { hasDefault, encoded };
45
- }
46
+ return parsed.programs.map(program => {
47
+ assertProgramEntry(program, source);
48
+ const entry = program.code === undefined && program.file === undefined ? {...source,...program} : program;
46
49
 
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
+ 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;
50
56
 
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);
57
+ return entry;
58
+ });
54
59
  }
55
60
 
56
61
  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
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]]));
60
65
 
61
- for (const p of params.programs) assertProgramEntry(p);
62
- const { hasDefault, encoded } = encodeBatchPayload(params);
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"); }
63
72
 
64
- if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
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)};
65
75
 
66
- return applyDefaultData(JSON.parse(encoded), hasDefault);
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);
67
81
  }
68
82
 
69
83
  function batchTimeoutMs(params, config) {
@@ -132,6 +146,10 @@ class ProgramBatch {
132
146
  }
133
147
  }
134
148
 
149
+ /** Elapsed and limit: 'deadline or cancellation' alone cannot tell them apart. */
150
+ deadlineNote() {
151
+ return " (ran " + Math.round(performance.now() - this.started) + "ms of " + this.timeout + "ms)";
152
+ }
135
153
  takeSettled(result, i) {
136
154
  this.results.push(result);
137
155
  this.trace.push(...(result.details?.trace ?? []));
@@ -156,7 +174,7 @@ class ProgramBatch {
156
174
  this.takeSettled(settled[i], i);
157
175
  }
158
176
 
159
- if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain";
177
+ if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
160
178
  }
161
179
 
162
180
  sequentialStop(result, i) {
@@ -171,14 +189,15 @@ class ProgramBatch {
171
189
 
172
190
  if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
173
191
 
174
- if (this.combined.aborted || performance.now() >= this.deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
192
+ // The deadline explains a killed program better than "program N failed".
193
+ if (this.combined.aborted || performance.now() >= this.deadline) stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
175
194
 
176
195
  return stopped;
177
196
  }
178
197
 
179
198
  async runSequential() {
180
199
  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; }
200
+ if (this.combined.aborted || performance.now() >= this.deadline) { this.stopped = "deadline or cancellation; remaining programs did not run" + this.deadlineNote(); break; }
182
201
 
183
202
  const result = await this.runOne(program, i);
184
203
  this.live[i] = [];
@@ -34,7 +34,10 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
34
34
 
35
35
  signal?.throwIfAborted();
36
36
  // Do not silently replace invalid bytes in executable source. Preserve BOMs.
37
- const code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
37
+ let code;
38
+
39
+ try { code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); }
40
+ catch { throw new Error("program file " + file + " is not valid UTF-8 (encoded data could not be decoded); save it as UTF-8 text"); }
38
41
 
39
42
  if (code.length > chars) throw tooLarge();
40
43
 
@@ -1,19 +1,15 @@
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
-
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
- read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
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
- bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
15
- bash({command,args}) → literal argv
16
-
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.
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.
6
+ read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span, not the file.
7
+ read(path,{about}) → windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations.
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.
19
15
  `;
@@ -6,6 +6,7 @@ import { packageFinalReturn } from "../output/bottleneck.js";
6
6
  import { truncateChars } from "../output/format.js";
7
7
  import { isFunction, isObject, isString } from "../shared/decode.js";
8
8
  import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
9
+ import { errorContext } from "../shared/syntax-context.js";
9
10
 
10
11
  const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
11
12
 
@@ -221,7 +222,7 @@ function admitData(data, cap) {
221
222
  const encoded = JSON.stringify(data);
222
223
 
223
224
  if (encoded === undefined) return { error: "data must be JSON-serializable" };
224
- if (encoded.length > cap) return { error: "data exceeds " + cap + " characters; split literal inputs across invocations" };
225
+ 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" };
225
226
 
226
227
  return { data: JSON.parse(encoded) };
227
228
  } catch { return { error: "data must be JSON-serializable" }; }
@@ -327,7 +328,7 @@ class GuestRun {
327
328
  try { this.onTimeout?.(); } catch {}
328
329
 
329
330
  this.aborting = false;
330
- this.finish(this.fail(ABORT_MESSAGE));
331
+ this.finish(this.fail(ABORT_MESSAGE + " (ran " + Math.round(this.wall()) + "ms of " + this.timeoutMs + "ms)"));
331
332
  }
332
333
 
333
334
  postResult(message) {
@@ -346,7 +347,12 @@ class GuestRun {
346
347
 
347
348
  async drainPending(outcome) {
348
349
  if (this.pending.size || !outcome.ok) this.cancelHost();
349
- await Promise.race([Promise.allSettled(this.pending), new Promise(resolve => setTimeout(resolve, 250))]);
350
+ if (!this.pending.size) return;
351
+ let timer;
352
+
353
+ try {
354
+ await Promise.race([Promise.allSettled(this.pending), new Promise(resolve => { timer = setTimeout(resolve, 250); })]);
355
+ } finally { clearTimeout(timer); }
350
356
  if (this.pending.size && outcome.ok) this.hostError ??= "program completed with a host call still running";
351
357
  }
352
358
 
@@ -429,7 +435,14 @@ class GuestRun {
429
435
  if (!this.code.trim()) { this.finish(this.fail("code must be a non-empty string; no commands ran")); return false; }
430
436
 
431
437
  try { this.prepared = prepareProgram(this.code); }
432
- catch (error) { this.finish(this.fail("JavaScript syntax error: " + error.message + "; no commands ran. Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args}).")); return false; }
438
+ catch (error) {
439
+ const guidance = this.file === undefined
440
+ ? " Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args})."
441
+ : " Fix " + this.file + " and re-run.";
442
+
443
+ this.finish(this.fail("JavaScript syntax error" + (this.file === undefined ? "" : " in " + this.file) + ": " + error.message + "; no commands ran." + guidance + errorContext(this.code, error)));
444
+ return false;
445
+ }
433
446
 
434
447
  return true;
435
448
  }
@@ -19,6 +19,36 @@ export function looksLikePath(target) {
19
19
  );
20
20
  }
21
21
 
22
+ /** Transform children without copying unchanged result trees. Never mutate the input. */
23
+ export function mapChangedChildren(value, visit, context) {
24
+ const array = Array.isArray(value);
25
+
26
+ if (!array && !isObject(value)) return value;
27
+ let out = value;
28
+
29
+ for (const key of array ? value.keys() : Object.keys(value)) {
30
+ if (array && !(key in value)) continue;
31
+ const before = value[key];
32
+ const after = visit(before, context);
33
+
34
+ if (Object.is(before, after)) continue;
35
+ if (out === value) out = array ? value.slice() : { ...value };
36
+ // Define rather than assign: "__proto__" must remain an ordinary data key.
37
+ Object.defineProperty(out, key, { value: after, enumerable: true, writable: true, configurable: true });
38
+ }
39
+
40
+ return out;
41
+ }
42
+
43
+ const MODEL_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
44
+
45
+ /** Reject unsupported attachments before they can poison the next model request. */
46
+ export function assertModelImageMime(mimeType) {
47
+ if (!MODEL_IMAGE_MIMES.has(mimeType)) {
48
+ throw new Error("unsupported image attachment type " + mimeType + "; model images require PNG, JPEG, GIF, or WebP. Convert the image to PNG before reading/returning it; no image attached");
49
+ }
50
+ }
51
+
22
52
  const MAX_DEPTH = 64;
23
53
 
24
54
  const MAX_TYPED_ARRAY = 4096;
@@ -0,0 +1,31 @@
1
+ /** Bounded source context for parse and syntax diagnostics. */
2
+
3
+ export function sourceContext(source, line, column) {
4
+ if (!Number.isInteger(line) || line < 1) return "";
5
+ const text = String(source).split("\n")[line - 1];
6
+
7
+ if (text === undefined) return "";
8
+ const shown = text.length > 160 ? text.slice(0, 160) : text;
9
+ const caret = Number.isInteger(column) ? " ".repeat(Math.min(column, shown.length)) + "^" : "";
10
+
11
+ return "\n " + shown + (caret ? "\n " + caret : "");
12
+ }
13
+
14
+ /** acorn-style error that carries a loc, when it has one. */
15
+ export function errorContext(source, error) {
16
+ return sourceContext(source, error?.loc?.line, error?.loc?.column);
17
+ }
18
+
19
+ /** V8 "(line 5 column 3)" or "at position 42" parse messages → a position. */
20
+ export function parsePosition(message, source) {
21
+ const located = /\(line (\d+) column (\d+)\)/.exec(String(message));
22
+
23
+ if (located) return { line: Number(located[1]), column: Number(located[2]) };
24
+ const offsetMatch = /at position (\d+)/.exec(String(message));
25
+
26
+ if (!offsetMatch) return null;
27
+ const before = String(source).slice(0, Number(offsetMatch[1]));
28
+ const lines = before.split("\n");
29
+
30
+ return { line: lines.length, column: lines.at(-1).length };
31
+ }
@@ -0,0 +1,17 @@
1
+ /** Strict decode: binary or non-UTF-8 text must fail loudly, never as U+FFFD. */
2
+ export function decodeUtf8Strict(bytes, target) {
3
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
4
+ catch {
5
+ throw new Error("not valid UTF-8 (binary or non-UTF-8 text): " + target + "; inspect or convert it with bash (for example iconv -f latin1 -t utf8 or xxd)");
6
+ }
7
+ }
8
+
9
+ /** Prefix window: the byte cut may split a character, so drop one partial tail. */
10
+ export function decodeUtf8Window(bytes) {
11
+ for (let cut = 0; cut <= 3 && cut < bytes.length; cut++) {
12
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytes.length - cut)); }
13
+ catch { /* a partial character at the cut is expected */ }
14
+ }
15
+
16
+ return bytes.toString("utf8");
17
+ }
package/src/ui/render.js CHANGED
@@ -373,6 +373,8 @@ function formatTarget(op, budget) {
373
373
  function opMarker(theme, op, isPartial, isError) {
374
374
  if (op.ok === false) return theme.fg("error", "×");
375
375
 
376
+ if (op.mutationAttempt) return theme.fg("warning", "·");
377
+
376
378
  if (op.ok === true) return theme.fg("success", "✓");
377
379
 
378
380
  if (isPartial) return theme.fg("dim", "·");
@@ -430,8 +432,9 @@ function formatOpRow(theme, op, width, isPartial, isError) {
430
432
  const duration = theme.fg("dim", durationText.padStart(DURATION_COL));
431
433
  const exit = appendExit(theme, op);
432
434
  const counts = appendDiffCounts(theme, op);
433
- const prefix = `${marker} ${tool} ${duration} ` + exit.text + counts.text;
434
- const used = 2 + toolText.length + 1 + DURATION_COL + 2 + exit.width + counts.width;
435
+ const outcome = op.mutationAttempt ? "attempted " : "";
436
+ const prefix = `${marker} ${tool} ${duration} ` + exit.text + counts.text + theme.fg("warning", outcome);
437
+ const used = 2 + toolText.length + 1 + DURATION_COL + 2 + exit.width + counts.width + outcome.length;
435
438
 
436
439
  return opRowSuffix(theme, op, prefix, Math.max(1, width - used));
437
440
  }
@@ -460,10 +463,22 @@ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, is
460
463
  if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
461
464
  }
462
465
 
463
- function appendError(lines, theme, payload, expanded, width) {
464
- const error = "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error");
466
+ function appendError(lines, theme, payload, _expanded, width) {
467
+ const errors = [payload?.error || "error"];
468
+ for (const [i, program] of (payload?.programs ?? []).entries()) {
469
+ if (program.details?.ok === false) errors.push(`program ${i + 1}: ${program.details.error || resultTextContent(program)}`);
470
+ }
471
+ for (const error of errors) for (const line of resultLines("✗ " + cleanBlockText(error), width)) lines.push(theme.fg("error", line));
472
+ }
465
473
 
466
- for (const line of expanded ? resultLines(error, width) : error.split("\n")) lines.push(theme.fg("error", line));
474
+ function appendMutations(lines, theme, payload, width) {
475
+ const m = payload?.mutations;
476
+ if (!m || !(m.committed || m.rolledBack || m.external || m.pendingCommits || m.recoveryFailed)) return;
477
+ const summary = `file versions: committed=${m.committed || 0} rolledBack=${m.rolledBack || 0}`
478
+ + (m.external ? `; external calls attempted=${m.external}` : "")
479
+ + (m.pendingCommits ? `; pendingCommits=${m.pendingCommits}` : "")
480
+ + (m.recoveryFailed ? "; recovery failed: inspect files" : "");
481
+ for (const line of resultLines(summary, width)) lines.push(theme.fg(m.rolledBack || m.recoveryFailed ? "warning" : "dim", line));
467
482
  }
468
483
 
469
484
  function appendResult(lines, theme, payload, expanded, width) {
@@ -502,17 +517,22 @@ function appendOverflow(lines, theme, trace, maxOps, isPartial) {
502
517
  }
503
518
 
504
519
  function appendEmptyOps(lines, theme, ops, isError, isPartial) {
505
- if (ops.length === 0 && !isError && !isPartial) lines.push(theme.fg("dim", "no adapter calls"));
520
+ if (ops.length === 0 && !isError && !isPartial) lines.push(theme.fg("dim", "JavaScript-only execution"));
506
521
  }
507
522
 
508
523
  function buildBodyLines(theme, width, { payload, context, expanded, isPartial, isError }) {
509
524
  const trace = traceFor(payload, context);
510
525
  const { maxOps, maxDiffLines } = bodyLimits(expanded, isPartial);
511
526
  const ops = operationsFromTrace(visibleTrace(trace, maxOps, isPartial));
527
+ // Trace success records an operation, not persistence of every staged version.
528
+ // Mixed rollback/commit counts cannot safely be attributed to individual rows.
529
+ for (const op of ops) op.mutationAttempt = op.ok === true && ["write", "edit", "patch"].includes(op.tool)
530
+ && (isPartial || isError || payload?.mutations?.rolledBack > 0 || payload?.mutations?.recoveryFailed);
512
531
  const lines = [];
513
532
  appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
514
533
  appendOverflow(lines, theme, trace, maxOps, isPartial);
515
- appendEmptyOps(lines, theme, ops, isError, isPartial);
534
+ if (!isPartial) appendMutations(lines, theme, payload, width);
535
+ if (Array.isArray(payload?.trace)) appendEmptyOps(lines, theme, ops, isError, isPartial);
516
536
  appendTail(lines, theme, payload, expanded, isError, width, ops.length === 0 && !isPartial);
517
537
 
518
538
  return { lines, opCount: trace.length };
@@ -594,12 +614,14 @@ function syncState(context, payload) {
594
614
  if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) context.state.wallMs = payload.wallMs;
595
615
  }
596
616
 
597
- function errorPayload(result) {
598
- return result?.isError ? { ok: false, error: result.content?.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") } : undefined;
617
+ function resultTextContent(result) {
618
+ return result?.content?.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") || "";
599
619
  }
600
620
 
601
- function payloadFromResult(result) {
602
- return result?.details ?? errorPayload(result);
621
+ function payloadFromResult(result, hostError) {
622
+ const payload = result?.details;
623
+ if (hostError) return {...payload, ok:false, error:payload?.error || resultTextContent(result) || "tool execution failed"};
624
+ return payload ?? {result:resultTextContent(result)};
603
625
  }
604
626
 
605
627
  function bindResultCard(host, options, context) {
@@ -619,9 +641,11 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
619
641
  themeArg,
620
642
  contextArg,
621
643
  );
622
- const payload = payloadFromResult(result);
644
+ // Pi omits isError from result and supplies it through render context.
645
+ const hostError = result?.isError === true || context?.isError === true || options?.isError === true;
646
+ const payload = payloadFromResult(result, hostError);
623
647
  syncState(context, payload);
624
- const isError = result?.isError || payload?.ok === false;
648
+ const isError = hostError || payload?.ok === false;
625
649
  const comp = bindResultCard(host, options, context);
626
650
  comp.set(theme, { payload, context, args, expanded, isPartial, isError, host });
627
651