pi-supernova 0.8.0 → 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.
package/README.md CHANGED
@@ -12,47 +12,56 @@ Ordinary JavaScript control flow remains available; the guest command bindings
12
12
  are only `read`, `edit`, `write`, and `bash`. Supernova supplies retrieval,
13
13
  transactional file operations, batching, bounded results and the grouped nova UI.
14
14
 
15
- ## Unreleased
15
+ ## What is new in 0.8.0
16
16
 
17
17
  - **Shared program source:** top-level `code` or `file` supplies a batch default;
18
- entries may override it. No temporary program file is required for inline reuse.
18
+ entries may override it. A shared program is sent once instead of in every entry,
19
+ and defaults count once against the 48,000-character admission cap.
19
20
  - **Explicit object defaults:** `mergeData:true` shallowly overlays per-entry data
20
- onto common data. Existing whole-input replacement remains the default.
21
-
22
- - Failed `edit` checkpoints now roll back and **throw**. Catch explicitly when
23
- rejecting a candidate is intentional; ignored failures no longer report success.
24
- - Read errors state both size limits and executable recovery examples. Markdown
25
- edits skip code-reference searches; exact-symbol usage evidence excludes generic
26
- matches and keeps late references inside the returned window.
27
- - Returned image sets over 16 images / 20 MiB fail with aggregate counts and bytes,
28
- rather than silently omitting attachments. Pending file changes roll back.
29
-
30
- - **`parallel: true` on `programs`:** independent entries run at once (up to 8),
31
- keep result order, and do not stop siblings on failure. Sequential is still
32
- the default.
33
- - **JSON `.length`:** `read({path, json:".items.length"})` returns the array
34
- length without dumping the array.
35
- - Prefer `edit` for a file you already read; `write` still replaces the file.
36
-
37
- ## What is new in 0.7.0
38
-
39
- - **Faster batches:** guest workers pipeline their successor, so sequential
40
- programs run about twice as fast with no token cost.
41
- - **Hardened guest:** `process.kill` is sealed out; patch hunks report
42
- relocation and ambiguous hunks fail instead of misapplying.
43
- - **Leaner receipts:** multi-edit output caps at 32 matches with exact totals,
44
- write receipts go workspace-relative, evidence/outline reads go compact.
45
-
46
- ## What is new in 0.6.0
47
-
48
- - **Shared batch input:** supply top-level `data` once; each program gets an
49
- independent copy unless it supplies its own replacement data.
50
- - **Conflict protection:** byte snapshots survive partial reads and body-cache
51
- eviction; receipt generation cannot silently rebase a pending write.
52
- - **Read fidelity:** staged declarations remain discoverable in large/new files,
53
- line windows preserve source endings, and `complete` always means the whole file.
54
- - **Explicit failures:** incompatible read modes, budget-limited matches, captured
55
- overrides and conflicting new-file aliases no longer silently change outcomes.
21
+ onto common data (entry keys win; nested objects are replaced). Whole-input
22
+ replacement remains the default.
23
+ - **Checkpoint failures throw:** a failed `edit(async () => {...})` rolls back and
24
+ rethrows its original cause; catch explicitly when rejecting a candidate is
25
+ intentional. Ignored failures no longer report success.
26
+ - **Accurate failure cards:** the nova card reads the host's error flag, shows the
27
+ original cause and `committed`/`rolledBack` totals, marks writes whose
28
+ persistence cannot be attributed as attempted, and labels pure JavaScript runs
29
+ instead of "complete".
30
+ - **Bounded, explicit reads:** errors state both limits (`160 lines / 8192
31
+ characters`) with copyable recovery (`offset`, `about`, `complete:true`, and
32
+ `Promise.allSettled` for optional siblings). Markdown edits skip code-reference
33
+ searches; exact-symbol evidence excludes generic matches.
34
+ - **Fail-closed images:** unsupported formats (for example BMP) fail before model
35
+ delivery with PNG-conversion guidance, and sets over 16 images / 20 MiB report
36
+ aggregate sizes instead of silently omitting attachments. Pending changes roll back.
37
+ - **Shell follows the program clock:** `bash()` inherits the program's `timeoutMs`;
38
+ explicit per-command limits still win.
39
+
40
+ ### Tokens: 0.7.1 to 0.8.0 (`js-tiktoken`, `o200k_base` / `cl100k_base`)
41
+
42
+ | Metric | 0.7.1 | 0.8.0 | Change |
43
+ |---|---:|---:|---:|
44
+ | Standing definition per request | 596 / 588 | 631 / 626 | +35 / +38 |
45
+ | Frozen 6-call mixed workload, total traffic | 9,596 / 9,458 | 9,841 / 9,724 | +2.6% / +2.8% |
46
+ | 16-program job with shared source + data (32 files) | 17,771 / 17,595 | 3,587 / 3,533 | -79.8% / -79.9% |
47
+ | 8 programs sharing a 48-path input | 16,309 / 14,649 | 5,499 / 5,197 | -66.3% / -64.5% |
48
+
49
+ Rows 3-4 deliver identical complete outputs and files; only argument placement
50
+ changes. Row 2 repeats no inputs, so it pays the +35-token guidance and nothing
51
+ else. Method, gates and limits: [TOKEN_COSTS.md](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/TOKEN_COSTS.md).
52
+
53
+ ### Speed: engine micro-benchmarks (Apple M5 Max, Node 26.7)
54
+
55
+ | Benchmark | Before | After | Change |
56
+ |---|---:|---:|---:|
57
+ | Package 200-row report (median, 10k iterations) | 0.1665 ms | 0.1314 ms | -21% |
58
+ | Package nested source object (median) | 0.0366 ms | 0.0240 ms | -35% |
59
+ | Idle worker exit | 267 ms | 17 ms | -94% |
60
+ | 8-file read wave p50 / p95 (300 samples) | 1.90 / 2.74 ms | 1.77 / 2.53 ms | -7% / -8% |
61
+ | Cold unbatched p95 vs coalesced warm p95 (8 reads) | 13.85 ms | 2.39 ms | -83% |
62
+
63
+ Identical output hashes before and after. Local engine benchmarks, not end-to-end
64
+ agent latency or provider time.
56
65
 
57
66
  See the [changelog](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/CHANGELOG.md)
58
67
  and [token measurements](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/TOKEN_COSTS.md).
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.8.1] - 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - Reads, windows, edits and appends reject **non-UTF-8** files with the path and
10
+ a conversion hint instead of returning U+FFFD, which an edit or append could
11
+ have written back as corruption. Prefix windows still drop one partial
12
+ character at the byte cut; diff/receipt snapshots stay tolerant so an explicit
13
+ `replace:true` still overwrites any file.
14
+ - Unknown options now fail loudly on every command: `read` (for example a
15
+ foreign `{start,end}` window, which used to return the whole file) and
16
+ `bash`/`write`/`edit` (`env`, `maxOutputChars`, `mode`, `all` were dropped
17
+ silently). Each error names the option and the supported set.
18
+ - Filesystem failures name the path or command: write and edit no longer say
19
+ "read path is a directory"; `ENOTDIR`/`EACCES`/`EPERM`/`EROFS`/`ENOSPC` no
20
+ longer leak raw codes or the `.supernova-<uuid>.new` temporary; `bash cwd`
21
+ must be a directory (`spawn ENOTDIR` is gone).
22
+ - `readWindow` no longer leaks an unhandled rejection while `finally` awaits
23
+ `file.close()`; failing window reads settle cleanly.
24
+ - Edit target misses report the closest matching line with its exact bytes
25
+ instead of only the file head; multi-edit failures name the entry
26
+ (`edit 2 of 3`); `edit(path,{oldText,newText}|{edits}|{patch})` dispatches.
27
+ - Syntax errors quote the offending source line and column with a caret;
28
+ `file:` programs name the file, and invalid UTF-8 names the file too.
29
+ - JSON projection reports the parse position with the offending line when V8
30
+ provides one, and ignores one leading BOM (also in write checks).
31
+ - Timeout and batch-deadline messages report elapsed time against the limit, and
32
+ a deadline-killed program is no longer reported as a plain program failure.
33
+
34
+ ### Internals
35
+
36
+ - 259 package tests, actual Pi + OMP host smoke, isolated Spark run, a
37
+ 536-program stress pass, and both frozen token gates.
38
+
5
39
  ## [0.8.0] - 2026-09-19
6
40
 
7
41
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "CodeMode for Pi and OMP: read, edit, write and bash, with transactional files, source views and shared-input program batches.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import { isString } from "../shared/decode.js";
2
3
  import { unwrapIfFullyQuoted } from "../fs/text-ops.js";
3
4
  import { sourceForReferences } from "../fs/source-window.js";
@@ -35,6 +36,12 @@ export function createBash(ctx) {
35
36
  const { literal, command, argv } = parseBash(params);
36
37
  const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
37
38
 
39
+ if (params?.cwd !== undefined) {
40
+ const st = await fs.stat(targetCwd).catch(() => null);
41
+
42
+ if (!st?.isDirectory()) throw new Error("bash cwd is not a directory: " + params.cwd);
43
+ }
44
+
38
45
  const transactionBarrier = await vfs.prepareExternalMutation("bash");
39
46
  let res;
40
47
 
@@ -8,6 +8,7 @@ import { selectEvidence } from "../context/evidence.js";
8
8
  import { WorkspaceIndex } from "../context/repo-index.js";
9
9
  import { outlineFile } from "../context/outline.js";
10
10
  import { MAX_JSON_BYTES, jsonProjector } from "../fs/json-read.js";
11
+ import { decodeUtf8Strict, decodeUtf8Window } from "../shared/utf8.js";
11
12
  import { normalizeRead, classifyRead, needsProbe, SESSION_URI, buildJsonRouting, buildSelectionRouting, routingText } from "../contract/read.js";
12
13
  import { resolveWorkspacePath, runCommand, relativeSlash } from "../fs/workspace.js";
13
14
  import {
@@ -19,6 +20,7 @@ import {
19
20
  } from "../fs/text-ops.js";
20
21
  import { imageTooLarge, missingFile, IMAGE_MAX_BYTES, LARGE_FILE_BYTES, ABOUT_TOKEN_MAX, IMAGE_MIME, RAW_JSON_CHARS, RAW_SOURCE_CHARS, RAW_SOURCE_LINES, ROUTING_MAX_CHARS } from "./errors.js";
21
22
  import { outlineOptions, recordOutlineOrigins, createReferenceFinder } from "./refs.js";
23
+ import { sourceContext, parsePosition } from "../shared/syntax-context.js";
22
24
 
23
25
  export function createRead(ctx) {
24
26
  const { getCwd, vfs, config, index, ledger, hooks, reads } = ctx;
@@ -230,12 +232,18 @@ export function createRead(ctx) {
230
232
  try { return await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
231
233
  catch (error) {
232
234
  if (error.code === "ENOENT") throw missingFile(targetPath);
235
+ if (error.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + targetPath + " is a file, not a directory");
236
+ if (error.code === "EISDIR") throw new Error("path is a directory, not a file: " + targetPath);
237
+ if (error.code === "EACCES" || error.code === "EPERM") throw new Error("permission denied reading " + targetPath + ": check the file mode (for example bash chmod)");
233
238
  throw error;
234
239
  }
235
240
  }
236
241
 
237
242
  async function finishFileWindow(targetPath, stat, startLine, scan) {
238
- const text = Buffer.concat(scan.parts, scan.collected).toString("utf8");
243
+ // A truncated window can cut a multi-byte character; a window that reached
244
+ // EOF must decode strictly, so a binary file cannot masquerade as text.
245
+ const bytes = Buffer.concat(scan.parts, scan.collected);
246
+ const text = scan.startByte + scan.collected >= stat.size ? decodeUtf8Strict(bytes, targetPath) : decodeUtf8Window(bytes);
239
247
  const satisfied = (scan.done && scan.startByte + scan.collected >= scan.doneByte) || scan.startByte + scan.collected >= stat.size;
240
248
  const whole = startLine === 1 && scan.startByte === 0 && scan.startByte + scan.collected >= stat.size;
241
249
  await vfs.recordExpected(targetPath, stat);
@@ -262,7 +270,11 @@ export function createRead(ctx) {
262
270
 
263
271
  if (!scan.started) return emptyWindow(targetPath, stat);
264
272
 
265
- return finishFileWindow(targetPath, stat, startLine, scan);
273
+ // Await inside the try: returning the promise directly leaves its
274
+ // rejection unobserved while the finally awaits file.close().
275
+ const window = await finishFileWindow(targetPath, stat, startLine, scan);
276
+
277
+ return window;
266
278
  } finally {
267
279
  await file.close();
268
280
  }
@@ -501,13 +513,20 @@ export function createRead(ctx) {
501
513
  return parts;
502
514
  }
503
515
 
516
+ /** RFC 8259 lets parsers ignore one leading BOM; files from Windows tools carry it. */
517
+ const stripBom = text => text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
518
+
504
519
  async function projectJson(rel, targetPath, params) {
505
520
  const project = jsonProjector(params.json);
506
521
  const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES, label: "JSON input" });
507
522
  let document;
508
523
 
509
- try { document = JSON.parse(text); }
510
- catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
524
+ try { document = JSON.parse(stripBom(text)); }
525
+ catch (error) {
526
+ const position = parsePosition(error.message, text);
527
+
528
+ throw new Error("invalid JSON in " + rel + ": " + error.message + "; the entire document must parse before projection" + (position ? sourceContext(text, position.line, position.column) : ""));
529
+ }
511
530
 
512
531
  const many = Array.isArray(params.json);
513
532
  const selectors = many ? params.json.map(String) : [params.json === true ? "." : String(params.json)];
@@ -44,7 +44,12 @@ function writeOutcome(rel, target, content, speculative, prevText, removedLines)
44
44
  export function createWrite(ctx) {
45
45
  const { getCwd, vfs, index } = ctx;
46
46
 
47
+ const WRITE_OPTION_KEYS = ["path", "content", "append", "replace", "allowReadArtifacts"];
48
+
47
49
  function assertWriteParams(params) {
50
+ const unknown = Object.keys(params ?? {}).filter(key => !WRITE_OPTION_KEYS.includes(key));
51
+
52
+ if (unknown.length) throw new Error("write does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are " + WRITE_OPTION_KEYS.join(", "));
48
53
  if (!isString(params?.content)) throw new Error("write requires string content");
49
54
  assertWriteAppendFlag(params.append);
50
55
  assertWriteArtifactsFlag(params.allowReadArtifacts);
@@ -58,13 +63,17 @@ export function createWrite(ctx) {
58
63
  }
59
64
 
60
65
  async function applyAppend(target, content, snap) {
61
- let { previous: prevText, overlay, existingBytes } = snap;
66
+ const { overlay, existingBytes } = snap;
62
67
 
63
68
  if (existingBytes > WRITE_APPEND_MAX_READ_BYTES) throw new Error("append input exceeds " + WRITE_APPEND_MAX_READ_BYTES + " bytes; stream it with bash redirection instead");
64
-
65
- if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
66
- try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
67
- catch (error) { if (error.code !== "ENOENT") throw error; }
69
+ // Append needs the real content: the diff snapshot may be a lossy decode, and
70
+ // concatenating that would silently corrupt a non-UTF-8 file.
71
+ let prevText;
72
+
73
+ try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
74
+ catch (error) {
75
+ if (error.code !== "ENOENT") throw error;
76
+ prevText = "";
68
77
  }
69
78
 
70
79
  return { content: prevText + content, prevText, removedLines: undefined };
@@ -26,8 +26,17 @@ function normalizeArgv(args) {
26
26
  } else args._directArgv = true;
27
27
  }
28
28
 
29
+ const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv"];
30
+
31
+ /** Unknown options used to be dropped silently: env/maxOutputChars never applied. */
32
+ function assertBashOptions(args) {
33
+ const unknown = Object.keys(args).filter(key => !BASH_OPTION_KEYS.includes(key));
34
+
35
+ if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs");
36
+ }
29
37
  export function normalizeBash(command, opts) {
30
38
  const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
39
+ assertBashOptions(args);
31
40
  normalizeArgv(args);
32
41
 
33
42
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
@@ -1,6 +1,6 @@
1
1
  import { isString, isObject, isFunction, isNumber } from "../shared/decode.js";
2
2
 
3
- export const EDIT_USAGE = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
3
+ export const EDIT_USAGE = 'invalid edit signature; use edit(path,oldText,newText), edit(path,{oldText,newText}), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
4
4
 
5
5
  function spanStart(value) {
6
6
  return isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
@@ -40,9 +40,17 @@ function namedEditObject(p, oldText, newText) {
40
40
  }
41
41
 
42
42
  function namedEditPositional(p, oldText, newText) {
43
- if (isObject(oldText) && !Array.isArray(oldText)) throw new Error(EDIT_USAGE);
43
+ if (Array.isArray(oldText)) return { path: p, edits: oldText };
44
44
 
45
- return Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
45
+ if (isObject(oldText)) {
46
+ // edit(path,{oldText,newText}|{edits}|{patch}): the path rides in either
47
+ // argument, but a conflicting path or third argument is still rejected.
48
+ if (newText !== undefined || (oldText.path !== undefined && oldText.path !== p)) throw new Error(EDIT_USAGE);
49
+
50
+ return { ...oldText, path: p };
51
+ }
52
+
53
+ return { path: p, oldText, newText };
46
54
  }
47
55
 
48
56
  function normalizeEditArgs(p, oldText, newText) {
@@ -79,9 +87,19 @@ function classifyReplacements(args) {
79
87
  return { kind: "edits", command: "edit", args };
80
88
  }
81
89
 
90
+ const EDIT_OPTION_KEYS = ["path", "oldText", "newText", "edits", "patch"];
91
+
92
+ /** A view object carries host fields; only named replacements are validated. */
93
+ function assertEditOptions(args) {
94
+ const unknown = Object.keys(args).filter(key => !EDIT_OPTION_KEYS.includes(key));
95
+
96
+ if (unknown.length) throw new Error("edit does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are path, oldText, newText, edits, patch");
97
+ }
98
+
82
99
  function classifyNamedEdit(p, oldText, newText) {
83
100
  const args = namedEditArgs(p, oldText, newText);
84
101
  assertNamedEditMode(args, oldText, newText);
102
+ assertEditOptions(args);
85
103
 
86
104
  return args.patch !== undefined ? classifyPatch(args) : classifyReplacements(args);
87
105
  }
@@ -5,6 +5,20 @@ export const SESSION_URI = /^(?:agent|artifact):\/\//i;
5
5
 
6
6
  const BOOL_KEYS = ["resolve", "complete", "outline", "evidence"];
7
7
 
8
+ const READ_OPTION_KEYS = ["path", "target", "about", "query", "offset", "limit", "json", "resolve", "complete", "outline", "evidence", "maxChars", "_independent"];
9
+
10
+ /** Unknown options used to be dropped silently: {start,end} read the whole file. */
11
+ function assertReadOptions(args) {
12
+ const unknown = Object.keys(args).filter(key => !READ_OPTION_KEYS.includes(key));
13
+
14
+ if (unknown.length === 0) return;
15
+ const windowHint = unknown.some(key => key === "start" || key === "end")
16
+ ? " For a line window use read(path, {offset:1, limit:80}): offset is the first line and limit is the line count."
17
+ : "";
18
+
19
+ throw new Error("read does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are " + READ_OPTION_KEYS.filter(key => key !== "_independent").join(", ") + "." + windowHint);
20
+ }
21
+
8
22
  export function isSessionUri(value) {
9
23
  return isString(value) && SESSION_URI.test(value);
10
24
  }
@@ -62,6 +76,7 @@ export function normalizeRead(params) {
62
76
 
63
77
  const args = sessionJsonArgs({ ...params, path: params.path ?? params.target });
64
78
  validateJsonRead(args);
79
+ assertReadOptions(args);
65
80
  assertReadFlags(args);
66
81
  assertExclusiveRead(args);
67
82
  assertReadPaths(args.path);
package/src/fs/check.js CHANGED
@@ -212,7 +212,8 @@ export function quickCheck(text, ext) {
212
212
 
213
213
  if (ext === ".json") {
214
214
  try {
215
- JSON.parse(text);
215
+ // RFC 8259: a single leading BOM is ignorable; do not flag valid JSON.
216
+ JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
216
217
 
217
218
  return { ok: true, kind: "json" };
218
219
  } catch (err) {
@@ -235,6 +235,22 @@ function duplicateEditError(target, content, index, second) {
235
235
  return new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lineAt(content, a)) + "\n" + formatNumberedLine(b, lineAt(content, b)));
236
236
  }
237
237
 
238
+ /** Exact bytes at the closest guess: a byte-for-byte miss is usually indentation drift. */
239
+ function nearMissPreview(content, oldText) {
240
+ const first = String(oldText).split("\n").find(line => line.trim().length > 0);
241
+
242
+ if (!first) return null;
243
+ const at = content.indexOf(first.trim());
244
+
245
+ if (at < 0) return null;
246
+ const line = lineNumberAt(content, at);
247
+ const start = Math.max(1, line - 2);
248
+ const from = lineStartIndex(content, start);
249
+ const shown = content.slice(from, lineEndIndex(content, from, Math.min(5, line - start + 3))).replace(/\r?\n$/, "");
250
+
251
+ return "first oldText line matches line " + line + " only after trimming; exact bytes there:\n"
252
+ + shown.split("\n").map((text, index) => formatNumberedLine(start + index, text)).join("\n");
253
+ }
238
254
  function matchReplacement(target, content, replacement) {
239
255
  if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
240
256
  throw new Error("edit requires non-empty oldText");
@@ -246,7 +262,7 @@ function matchReplacement(target, content, replacement) {
246
262
  const index = content.indexOf(oldText);
247
263
 
248
264
  if (index < 0) {
249
- throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
265
+ throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + (nearMissPreview(content, oldText) ?? numberedPreview(content)));
250
266
  }
251
267
  const second = content.indexOf(oldText, index + 1);
252
268
 
@@ -263,7 +279,13 @@ function assertNoOverlap(target, matches) {
263
279
 
264
280
  export function applyReplacements(target, content, requestedEdits) {
265
281
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
266
- const matches = requestedEdits.map((replacement) => matchReplacement(target, content, replacement));
282
+ const matches = requestedEdits.map((replacement, index) => {
283
+ try { return matchReplacement(target, content, replacement); }
284
+ catch (error) {
285
+ // Name the failing entry: a multi-edit miss is otherwise a guessing game.
286
+ throw requestedEdits.length === 1 ? error : new Error("edit " + (index + 1) + " of " + requestedEdits.length + ": " + error.message);
287
+ }
288
+ });
267
289
  matches.sort((a, b) => a.index - b.index);
268
290
  assertNoOverlap(target, matches);
269
291
  let updated = content;
@@ -380,7 +402,8 @@ async function snapshotLargeFile(vfs, target, overlay, signal) {
380
402
  }
381
403
 
382
404
  async function snapshotSmallFile(vfs, target, overlay) {
383
- try { return overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true }); }
405
+ // Diff/receipt snapshot only: a lossy decode is acceptable and must not block a replace.
406
+ try { return overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true, strict: false }); }
384
407
  catch (error) {
385
408
  if (error?.code !== "ENOENT") throw error;
386
409
 
@@ -391,6 +414,8 @@ async function snapshotSmallFile(vfs, target, overlay) {
391
414
  async function existingStat(target) {
392
415
  try { return await fs.stat(target); }
393
416
  catch (error) {
417
+ if (error.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
418
+
394
419
  if (error.code !== "ENOENT") throw error;
395
420
  }
396
421
  }
package/src/fs/vfs.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { isString } from "../shared/decode.js";
4
+ import { decodeUtf8Strict } from "../shared/utf8.js";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
6
 
6
7
  // Serialize validation + replacement across Supernova transactions in this host.
@@ -67,9 +68,10 @@ function overlayOrThrow(overlay, maxBytes, label) {
67
68
  }
68
69
 
69
70
  function assertReadableFile(stat, target) {
70
- if (stat.isDirectory()) throw new Error("read path is a directory, not a file: " + target);
71
+ // Callers are reads, writes, edits and patch application: name the path, not the caller.
72
+ if (stat.isDirectory()) throw new Error("path is a directory, not a file: " + target);
71
73
 
72
- if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
74
+ if (!stat.isFile()) throw new Error("path is not a regular file: " + target);
73
75
  }
74
76
 
75
77
  async function readLimitedBytes(file, stat, maxBytes, label, signal) {
@@ -88,7 +90,10 @@ async function readLimitedBytes(file, stat, maxBytes, label, signal) {
88
90
  }
89
91
 
90
92
  function remapReadError(err, target) {
91
- if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
93
+ if (err.code === "EISDIR") throw new Error("path is a directory, not a file: " + target);
94
+
95
+ if (err.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
96
+ if (err.code === "EACCES" || err.code === "EPERM") throw new Error("permission denied reading " + target + ": check the file mode (for example bash chmod)");
92
97
 
93
98
  if (err.code === "ENOENT") {
94
99
  const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question; use Promise.allSettled for optional reads to retain successful siblings)');
@@ -134,7 +139,16 @@ async function collectMissingAncestors(parent) {
134
139
  }
135
140
 
136
141
  async function writeTemporary(entry, content, stat) {
137
- await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
142
+ try {
143
+ await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
144
+ } catch (error) {
145
+ // Never leak the temporary name: name the destination and the real cause.
146
+ if (error?.code === "EACCES" || error?.code === "EPERM") throw new Error("permission denied writing " + entry.target + ": the directory or file is not writable");
147
+ if (error?.code === "EROFS") throw new Error("cannot write " + entry.target + ": the file system is read-only");
148
+ if (error?.code === "ENOSPC") throw new Error("cannot write " + entry.target + ": no space left on device");
149
+
150
+ throw error;
151
+ }
138
152
 
139
153
  if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
140
154
  }
@@ -258,7 +272,7 @@ export class CausalVfs {
258
272
  return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
259
273
  }
260
274
 
261
- async read(target, { preserveRead = false, maxBytes, label = "read input" } = {}) {
275
+ async read(target, { preserveRead = false, maxBytes, label = "read input", strict = true } = {}) {
262
276
  const overlay = this.getOverlay(target);
263
277
 
264
278
  if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label);
@@ -281,7 +295,7 @@ export class CausalVfs {
281
295
  // Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
282
296
  if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
283
297
 
284
- return bytes.toString("utf8");
298
+ return strict ? decodeUtf8Strict(bytes, target) : bytes.toString("utf8");
285
299
  } catch (err) {
286
300
  remapReadError(err, target);
287
301
  }
@@ -216,7 +216,12 @@ function attachCommandIO(state, options, argv, timeoutMs) {
216
216
  child.stderr.setEncoding("utf8");
217
217
  child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
218
218
  child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
219
- child.on("error", error => failCommand(state, error));
219
+ child.on("error", error => {
220
+ if (error?.code === "EACCES" || error?.code === "EPERM") failCommand(state, new Error("cannot execute " + argv[0] + ": permission denied (is it executable?)"));
221
+ else if (error?.code === "ENOENT") failCommand(state, new Error("command not found: " + argv[0]));
222
+ else if (error?.code === "ENOTDIR") failCommand(state, new Error("cannot run " + argv[0] + ": the working directory is not a directory"));
223
+ else failCommand(state, error);
224
+ });
220
225
  child.on("close", (code, signal) => onCommandClose(state, code, signal));
221
226
  options.signal?.addEventListener("abort", onAbort, { once: true });
222
227
 
@@ -146,6 +146,10 @@ class ProgramBatch {
146
146
  }
147
147
  }
148
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
+ }
149
153
  takeSettled(result, i) {
150
154
  this.results.push(result);
151
155
  this.trace.push(...(result.details?.trace ?? []));
@@ -170,7 +174,7 @@ class ProgramBatch {
170
174
  this.takeSettled(settled[i], i);
171
175
  }
172
176
 
173
- 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();
174
178
  }
175
179
 
176
180
  sequentialStop(result, i) {
@@ -185,14 +189,15 @@ class ProgramBatch {
185
189
 
186
190
  if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
187
191
 
188
- 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();
189
194
 
190
195
  return stopped;
191
196
  }
192
197
 
193
198
  async runSequential() {
194
199
  for (const [i, program] of this.programs.entries()) {
195
- 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; }
196
201
 
197
202
  const result = await this.runOne(program, i);
198
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
 
@@ -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
 
@@ -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) {
@@ -434,7 +435,14 @@ class GuestRun {
434
435
  if (!this.code.trim()) { this.finish(this.fail("code must be a non-empty string; no commands ran")); return false; }
435
436
 
436
437
  try { this.prepared = prepareProgram(this.code); }
437
- 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
+ }
438
446
 
439
447
  return true;
440
448
  }
@@ -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
+ }