pi-supernova 0.8.0 → 0.8.2

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,84 @@ 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.2
16
+
17
+ This patch release fixes shell failure handling, cancellation and parallel-batch
18
+ limits, and makes one-call batching guidance explicit.
19
+
20
+ - **Shell quoting stays intact:** quoted executable paths are no longer unwrapped.
21
+ Shell syntax errors suggest literal `bash({command,args})` with `data` for
22
+ embedded scripts, or a quoted heredoc. Commands are not rewritten or retried.
23
+ - **Validation before commit:** invalid timeouts and null-byte
24
+ arguments are rejected before the shell boundary flushes staged files.
25
+ - **Useful failure output:** long command labels are bounded so the original
26
+ stderr is not crowded out by a repeated script.
27
+ - **Timeouts retain diagnostics:** the outer program deadline stops the worker
28
+ and gives pending host calls a bounded drain to retain shell output. Explicit
29
+ cancellation is reported separately from timeout. The outer `timeoutMs` covers
30
+ every wait and command, including `sleep`.
31
+ - **Parallel budgets fail honestly:** exceeding the shared output, log or image
32
+ allowance marks the batch failed and stops queued entries. Already-running
33
+ entries settle; their results and completed commits remain. Aggregate logs stay
34
+ capped rather than multiplying the allowance per guest.
35
+ - **Batch known work in one call:** combine independent reads/checks with
36
+ `Promise.all`, then sequence edits and verification in the same program. Use
37
+ another invocation when returned evidence is needed for the next decision.
38
+
39
+ Verified on **macOS / Node 26.7**: 267 package tests (384 repository tests),
40
+ 2,328 stress invocations, actual Pi/OMP host checks, lint and both token-budget
41
+ checks. This is not a claim of exhaustive platform or formal mutation testing.
42
+
43
+ ## 0.8.0 features and measurements
16
44
 
17
45
  - **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.
46
+ entries may override it. A shared program is sent once instead of in every entry,
47
+ and defaults count once against the 48,000-character admission cap.
19
48
  - **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.
49
+ onto common data (entry keys win; nested objects are replaced). Whole-input
50
+ replacement remains the default.
51
+ - **Checkpoint failures throw:** a failed `edit(async () => {...})` rolls back and
52
+ rethrows its original cause; catch explicitly when rejecting a candidate is
53
+ intentional. Ignored failures no longer report success.
54
+ - **Accurate failure cards:** the nova card reads the host's error flag, shows the
55
+ original cause and `committed`/`rolledBack` totals, marks writes whose
56
+ persistence cannot be attributed as attempted, and labels pure JavaScript runs
57
+ instead of "complete".
58
+ - **Bounded, explicit reads:** errors state both limits (`160 lines / 8192
59
+ characters`) with copyable recovery (`offset`, `about`, `complete:true`, and
60
+ `Promise.allSettled` for optional siblings). Markdown edits skip code-reference
61
+ searches; exact-symbol evidence excludes generic matches.
62
+ - **Fail-closed images:** unsupported formats (for example BMP) fail before model
63
+ delivery with PNG-conversion guidance, and sets over 16 images / 20 MiB report
64
+ aggregate sizes instead of silently omitting attachments. Pending changes roll back.
65
+ - **Shell follows the program clock:** `bash()` inherits the program's `timeoutMs`;
66
+ explicit per-command limits still win.
67
+
68
+ ### Tokens: 0.7.1 to 0.8.0 (`js-tiktoken`, `o200k_base` / `cl100k_base`)
69
+
70
+ | Metric | 0.7.1 | 0.8.0 | Change |
71
+ |---|---:|---:|---:|
72
+ | Standing definition per request | 596 / 588 | 631 / 626 | +35 / +38 |
73
+ | Frozen 6-call mixed workload, total traffic | 9,596 / 9,458 | 9,841 / 9,724 | +2.6% / +2.8% |
74
+ | 16-program job with shared source + data (32 files) | 17,771 / 17,595 | 3,587 / 3,533 | -79.8% / -79.9% |
75
+ | 8 programs sharing a 48-path input | 16,309 / 14,649 | 5,499 / 5,197 | -66.3% / -64.5% |
76
+
77
+ Rows 3-4 deliver identical complete outputs and files; only argument placement
78
+ changes. Row 2 repeats no inputs, so it pays the +35-token guidance and nothing
79
+ else. Method, gates and limits: [TOKEN_COSTS.md](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/TOKEN_COSTS.md).
80
+
81
+ ### Speed: engine micro-benchmarks (Apple M5 Max, Node 26.7)
82
+
83
+ | Benchmark | Before | After | Change |
84
+ |---|---:|---:|---:|
85
+ | Package 200-row report (median, 10k iterations) | 0.1665 ms | 0.1314 ms | -21% |
86
+ | Package nested source object (median) | 0.0366 ms | 0.0240 ms | -35% |
87
+ | Idle worker exit | 267 ms | 17 ms | -94% |
88
+ | 8-file read wave p50 / p95 (300 samples) | 1.90 / 2.74 ms | 1.77 / 2.53 ms | -7% / -8% |
89
+ | Cold unbatched p95 vs coalesced warm p95 (8 reads) | 13.85 ms | 2.39 ms | -83% |
90
+
91
+ Identical output hashes before and after. Local engine benchmarks, not end-to-end
92
+ agent latency or provider time.
56
93
 
57
94
  See the [changelog](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/CHANGELOG.md)
58
95
  and [token measurements](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/TOKEN_COSTS.md).
@@ -72,14 +109,12 @@ Local checkout installs are for development, not distribution:
72
109
  pi install /path/to/pi-stack/packages/pi-supernova
73
110
  ```
74
111
 
75
- Git pushes do not update npm installations. Publish the new npm version first;
76
- then reinstall it in the host. Reinstall explicitly when an existing version
77
- range excludes the new minor version (`^0.6.0` excludes `0.7.0`). After 0.7.0 is
78
- published, pin that release with:
112
+ Git pushes do not update npm installations. Publish the new npm version first,
113
+ then reinstall it in your host. To pin **0.8.2** once it is published:
79
114
 
80
115
  ```bash
81
- pi install npm:pi-supernova@0.7.0
82
- omp install npm:pi-supernova@0.7.0
116
+ pi install npm:pi-supernova@0.8.2
117
+ omp install npm:pi-supernova@0.8.2
83
118
  ```
84
119
 
85
120
  In Pi, `pi list` shows the configured package sources. A local path uses that
@@ -126,6 +161,21 @@ payloads are not repeated in owned direct-execution errors;
126
161
  stdout/stderr, exit status and source context remain. Session environment variables are taken
127
162
  from the current execution context, not inherited from a different parent session.
128
163
 
164
+ Shell strings are executed unchanged, including quoted executable paths. For inline
165
+ Python/Node scripts, prefer literal argv with `data` instead of nested shell quotes:
166
+
167
+ ```json
168
+ {
169
+ "code": "return await bash({command:\"python3\",args:[\"-c\",data.script]});",
170
+ "data": {"script": "q = {'name': 'example'}\nprint(f\"{q['name']}\")\n"}
171
+ }
172
+ ```
173
+
174
+ Shell syntax errors keep the original diagnostic and suggest argv or a quoted
175
+ heredoc; commands are never automatically rewritten or retried. Invalid timeouts
176
+ and null-byte arguments fail before flushing staged changes. Failure labels are
177
+ bounded so a large script cannot crowd out its stderr.
178
+
129
179
  Source questions locate a declaration in one command. An exact
130
180
  declaration match uses one bounded direct ripgrep search, without a prerequisite
131
181
  file listing, persistent index, embeddings or summarization. A transient filename
@@ -340,6 +390,9 @@ Set `parallel: true` with `programs` to run independent entries concurrently
340
390
  in submission order. A failed entry does not stop siblings. Two entries writing
341
391
  the same file race: the losing commit reports a conflict. Sequential remains the
342
392
  default. `parallel` and `mergeData` are invalid on a lone `code` or `file` call.
393
+ Budget overflow marks the batch failed and stops queued entries; already-running
394
+ entries settle and their completed commits remain. Parallel execution does not
395
+ multiply the aggregate output, log, or image allowance.
343
396
 
344
397
  The outer deadline, host-call budget, log allowance, text budget and image limits
345
398
  are shared across the batch. Individual read budgets are not reduced. Every
@@ -371,10 +424,13 @@ For long archive scans, use resumable chunks or a host background-job tool and w
371
424
  progress records under `.work`. Shell commands inherit the current program
372
425
  `timeoutMs` unless they specify their own; increasing the outer deadline no longer
373
426
  leaves a hidden 60-second shell cap. Set the inner `bash` timeout shorter than the
374
- outer program timeout (for example 10 seconds inside a 20-second program) to retain
375
- bounded shell diagnostics. A hard guest deadline cannot guarantee pending shell
376
- output delivery; progress files survive shell execution but staged VFS writes may
377
- roll back.
427
+ outer program timeout (for example 10 seconds inside a 20-second program). The outer
428
+ deadline covers **all** waits and commands, including `sleep`; a shell's own `timeout`
429
+ command does not extend it. On a deadline or cancellation, the worker stops and
430
+ pending host calls get a bounded 250ms drain to retain owned-shell diagnostics and
431
+ finalize process termination. Non-cooperating host executors may still outlive that
432
+ drain. Cancellation is reported separately from timeout; neither triggers a retry.
433
+ Progress files survive shell execution but staged VFS writes may roll back.
378
434
 
379
435
  Large returned objects are bounded previews, not retained artifacts. Select fields
380
436
  and array windows before returning, rather than parsing a truncated preview.
@@ -423,6 +479,12 @@ continuation handles.
423
479
 
424
480
  ## Execution and automatic batching
425
481
 
482
+ Put already-known independent reads and checks in **one** Supernova program using
483
+ `Promise.all` (or `Promise.allSettled` when failures should remain independent).
484
+ Sequence edits and their known verification in that same program. Start another
485
+ invocation only when the returned evidence is needed to decide what to do next;
486
+ use focused read windows to keep the combined result within its output budget.
487
+
426
488
  Compatible independently started reads coalesce at the worker/host boundary.
427
489
  No additional batching command is required. Individual promises preserve their
428
490
  values, errors and per-read budgets. File reads have bounded parallelism; writes,
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.8.2] - 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - Shell commands preserve quoted executable paths. Parser failures suggest
10
+ literal argv for embedded scripts or a quoted heredoc, without rewriting or
11
+ automatically retrying commands.
12
+ - Invalid timeouts and null-byte arguments fail before flushing staged files.
13
+ Bounded command labels keep long scripts from crowding out diagnostics.
14
+ - Program deadlines retain pending shell output during bounded termination;
15
+ explicit cancellation is no longer misreported as a timeout.
16
+ - Parallel batches enforce shared output, log and image budgets instead of
17
+ reporting success after overflow. Queued entries stop, in-flight results and
18
+ completed commits remain, and aggregate logs stay capped.
19
+
20
+ ### Changed
21
+
22
+ - Guidance explicitly batches known reads/checks and edits/verification in one
23
+ invocation, and states that the outer deadline includes all waits and commands.
24
+
25
+ ### Internals
26
+
27
+ - 267 package tests (384 repository tests), 2,328 stress invocations, actual
28
+ Pi + OMP host checks, lint and both frozen token gates passed on macOS/Node 26.7.
29
+
30
+ ## [0.8.1] - 2026-09-19
31
+
32
+ ### Fixed
33
+
34
+ - Reads, windows, edits and appends reject **non-UTF-8** files with the path and
35
+ a conversion hint instead of returning U+FFFD, which an edit or append could
36
+ have written back as corruption. Prefix windows still drop one partial
37
+ character at the byte cut; diff/receipt snapshots stay tolerant so an explicit
38
+ `replace:true` still overwrites any file.
39
+ - Unknown options now fail loudly on every command: `read` (for example a
40
+ foreign `{start,end}` window, which used to return the whole file) and
41
+ `bash`/`write`/`edit` (`env`, `maxOutputChars`, `mode`, `all` were dropped
42
+ silently). Each error names the option and the supported set.
43
+ - Filesystem failures name the path or command: write and edit no longer say
44
+ "read path is a directory"; `ENOTDIR`/`EACCES`/`EPERM`/`EROFS`/`ENOSPC` no
45
+ longer leak raw codes or the `.supernova-<uuid>.new` temporary; `bash cwd`
46
+ must be a directory (`spawn ENOTDIR` is gone).
47
+ - `readWindow` no longer leaks an unhandled rejection while `finally` awaits
48
+ `file.close()`; failing window reads settle cleanly.
49
+ - Edit target misses report the closest matching line with its exact bytes
50
+ instead of only the file head; multi-edit failures name the entry
51
+ (`edit 2 of 3`); `edit(path,{oldText,newText}|{edits}|{patch})` dispatches.
52
+ - Syntax errors quote the offending source line and column with a caret;
53
+ `file:` programs name the file, and invalid UTF-8 names the file too.
54
+ - JSON projection reports the parse position with the offending line when V8
55
+ provides one, and ignores one leading BOM (also in write checks).
56
+ - Timeout and batch-deadline messages report elapsed time against the limit, and
57
+ a deadline-killed program is no longer reported as a plain program failure.
58
+
59
+ ### Internals
60
+
61
+ - 259 package tests, actual Pi + OMP host smoke, isolated Spark run, a
62
+ 536-program stress pass, and both frozen token gates.
63
+
5
64
  ## [0.8.0] - 2026-09-19
6
65
 
7
66
  ### 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.2",
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,5 +1,6 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import { isString } from "../shared/decode.js";
2
- import { unwrapIfFullyQuoted } from "../fs/text-ops.js";
3
+ import { normalizeBash } from "../contract/bash.js";
3
4
  import { sourceForReferences } from "../fs/source-window.js";
4
5
  import { resolveWorkspacePath, runCommand, clearPathCache } from "../fs/workspace.js";
5
6
 
@@ -16,7 +17,7 @@ export function createBash(ctx) {
16
17
  function bashCommand(params, literal) {
17
18
  if (params?.command !== undefined && !isString(params.command)) throw new Error("bash command must be a string");
18
19
  if (literal && (!isString(params.command) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
19
- const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
20
+ const command = String(params?.command ?? "");
20
21
 
21
22
  if (!command.trim()) throw new Error("bash requires command");
22
23
 
@@ -31,10 +32,17 @@ export function createBash(ctx) {
31
32
  }
32
33
 
33
34
  async function bash(params, signal) {
35
+ params = normalizeBash(params);
34
36
  const cwd = getCwd();
35
37
  const { literal, command, argv } = parseBash(params);
36
38
  const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
37
39
 
40
+ if (params?.cwd !== undefined) {
41
+ const st = await fs.stat(targetCwd).catch(() => null);
42
+
43
+ if (!st?.isDirectory()) throw new Error("bash cwd is not a directory: " + params.cwd);
44
+ }
45
+
38
46
  const transactionBarrier = await vfs.prepareExternalMutation("bash");
39
47
  let res;
40
48
 
@@ -42,7 +50,7 @@ export function createBash(ctx) {
42
50
  res = await runCommand(argv, {
43
51
  cwd: targetCwd,
44
52
  env: hooks.commandEnv(),
45
- commandLabel: literal ? command : undefined,
53
+ commandLabel: command,
46
54
  timeoutMs: params?.timeoutMs === undefined ? config.timeoutMs : params.timeoutMs,
47
55
  signal,
48
56
  maxOutputChars: config.maxCallResultChars,
@@ -60,7 +68,12 @@ export function createBash(ctx) {
60
68
  const { stdout, stderr } = res;
61
69
  let text = combineBashText(stdout, stderr);
62
70
 
63
- if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text, signal, ledger);
71
+ if (res.exitCode !== 0) {
72
+ if (!literal && /\bbash: (?:-c: )?line \d+: (?:syntax error|unexpected EOF)/.test(stderr)) {
73
+ text += '\nhint: Bash could not parse the command. For embedded scripts use literal argv, e.g. bash({command:"python3",args:["-c",data.script]}), or a quoted heredoc for shell pipelines. Do not blindly retry: earlier commands may have run.';
74
+ }
75
+ text += await sourceForReferences(cwd, targetCwd, text, signal, ledger);
76
+ }
64
77
 
65
78
  return {
66
79
  content: [{ type: "text", text }],
@@ -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 };
@@ -17,7 +17,10 @@ function normalizeArgv(args) {
17
17
  throw new Error(`${ARGV_ERROR}; args[${i}] is ${type}; check the supplied data fields and pass each argument as a string`);
18
18
  }
19
19
  }
20
- args.args = args.args.map(String);
20
+ args.args = args.args.map((arg, i) => {
21
+ if (arg.includes("\0")) throw new Error(`bash args[${i}] must not contain null bytes`);
22
+ return String(arg);
23
+ });
21
24
 
22
25
  if (process.platform === "win32") {
23
26
  delete args._directArgv;
@@ -26,11 +29,28 @@ function normalizeArgv(args) {
26
29
  } else args._directArgv = true;
27
30
  }
28
31
 
32
+ const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv"];
33
+
34
+ /** Unknown options used to be dropped silently: env/maxOutputChars never applied. */
35
+ function assertBashOptions(args) {
36
+ const unknown = Object.keys(args).filter(key => !BASH_OPTION_KEYS.includes(key));
37
+
38
+ 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");
39
+ }
29
40
  export function normalizeBash(command, opts) {
30
41
  const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
42
+ assertBashOptions(args);
43
+ if (!isString(args.command) || !args.command.trim()) throw new Error("bash requires a non-empty command string");
44
+ if (args.command.includes("\0")) throw new Error("bash command must not contain null bytes");
31
45
  normalizeArgv(args);
32
46
 
33
47
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
48
+ // Reject before the host's external-mutation barrier can flush staged files.
49
+ if (args.timeoutMs !== undefined) {
50
+ const timeout = Number(args.timeoutMs);
51
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
52
+ args.timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(timeout)));
53
+ }
34
54
 
35
55
  return args;
36
56
  }
@@ -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) {
@@ -81,21 +81,6 @@ export function resultDiff(response) {
81
81
  return isObject(details) ? details.diff : undefined;
82
82
  }
83
83
 
84
- /** Unwrap a single matching quote pair around the whole string (`'git status'`). */
85
- export function unwrapIfFullyQuoted(s) {
86
- if (s.length < 2) return s;
87
- const q = s[0];
88
-
89
- if (q !== "'" && q !== '"') return s;
90
-
91
- if (s[s.length - 1] !== q) return s;
92
- const inner = s.slice(1, -1);
93
-
94
- if (inner.includes(q)) return s;
95
-
96
- return inner;
97
- }
98
-
99
84
  function totalContentLines(text) {
100
85
  if (text === "") return 1;
101
86
 
@@ -235,6 +220,22 @@ function duplicateEditError(target, content, index, second) {
235
220
  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
221
  }
237
222
 
223
+ /** Exact bytes at the closest guess: a byte-for-byte miss is usually indentation drift. */
224
+ function nearMissPreview(content, oldText) {
225
+ const first = String(oldText).split("\n").find(line => line.trim().length > 0);
226
+
227
+ if (!first) return null;
228
+ const at = content.indexOf(first.trim());
229
+
230
+ if (at < 0) return null;
231
+ const line = lineNumberAt(content, at);
232
+ const start = Math.max(1, line - 2);
233
+ const from = lineStartIndex(content, start);
234
+ const shown = content.slice(from, lineEndIndex(content, from, Math.min(5, line - start + 3))).replace(/\r?\n$/, "");
235
+
236
+ return "first oldText line matches line " + line + " only after trimming; exact bytes there:\n"
237
+ + shown.split("\n").map((text, index) => formatNumberedLine(start + index, text)).join("\n");
238
+ }
238
239
  function matchReplacement(target, content, replacement) {
239
240
  if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
240
241
  throw new Error("edit requires non-empty oldText");
@@ -246,7 +247,7 @@ function matchReplacement(target, content, replacement) {
246
247
  const index = content.indexOf(oldText);
247
248
 
248
249
  if (index < 0) {
249
- throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
250
+ throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + (nearMissPreview(content, oldText) ?? numberedPreview(content)));
250
251
  }
251
252
  const second = content.indexOf(oldText, index + 1);
252
253
 
@@ -263,7 +264,13 @@ function assertNoOverlap(target, matches) {
263
264
 
264
265
  export function applyReplacements(target, content, requestedEdits) {
265
266
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
266
- const matches = requestedEdits.map((replacement) => matchReplacement(target, content, replacement));
267
+ const matches = requestedEdits.map((replacement, index) => {
268
+ try { return matchReplacement(target, content, replacement); }
269
+ catch (error) {
270
+ // Name the failing entry: a multi-edit miss is otherwise a guessing game.
271
+ throw requestedEdits.length === 1 ? error : new Error("edit " + (index + 1) + " of " + requestedEdits.length + ": " + error.message);
272
+ }
273
+ });
267
274
  matches.sort((a, b) => a.index - b.index);
268
275
  assertNoOverlap(target, matches);
269
276
  let updated = content;
@@ -380,7 +387,8 @@ async function snapshotLargeFile(vfs, target, overlay, signal) {
380
387
  }
381
388
 
382
389
  async function snapshotSmallFile(vfs, target, overlay) {
383
- try { return overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true }); }
390
+ // Diff/receipt snapshot only: a lossy decode is acceptable and must not block a replace.
391
+ try { return overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true, strict: false }); }
384
392
  catch (error) {
385
393
  if (error?.code !== "ENOENT") throw error;
386
394
 
@@ -391,6 +399,8 @@ async function snapshotSmallFile(vfs, target, overlay) {
391
399
  async function existingStat(target) {
392
400
  try { return await fs.stat(target); }
393
401
  catch (error) {
402
+ if (error.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
403
+
394
404
  if (error.code !== "ENOENT") throw error;
395
405
  }
396
406
  }
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
  }
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { constants } from "node:os";
5
5
  import { isString } from "../shared/decode.js";
6
+ import { truncateChars } from "../output/format.js";
6
7
 
7
8
  let cachedCwd = null;
8
9
 
@@ -211,12 +212,20 @@ function attachCommandIO(state, options, argv, timeoutMs) {
211
212
  clearTimeout(state.escalation);
212
213
  options.signal?.removeEventListener("abort", onAbort);
213
214
  };
214
- state.timer = setTimeout(() => terminateCommand(state, new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
215
+ state.timer = setTimeout(() => terminateCommand(state, new Error(
216
+ "command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
217
+ + "\nhint: Increase this bash timeoutMs and the outer supernova timeoutMs, or split the work. Sleeps and every command in a shell chain share the same limit."
218
+ )), timeoutMs);
215
219
  child.stdout.setEncoding("utf8");
216
220
  child.stderr.setEncoding("utf8");
217
221
  child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
218
222
  child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
219
- child.on("error", error => failCommand(state, error));
223
+ child.on("error", error => {
224
+ if (error?.code === "EACCES" || error?.code === "EPERM") failCommand(state, new Error("cannot execute " + argv[0] + ": permission denied (is it executable?)"));
225
+ else if (error?.code === "ENOENT") failCommand(state, new Error("command not found: " + argv[0]));
226
+ else if (error?.code === "ENOTDIR") failCommand(state, new Error("cannot run " + argv[0] + ": the working directory is not a directory"));
227
+ else failCommand(state, error);
228
+ });
220
229
  child.on("close", (code, signal) => onCommandClose(state, code, signal));
221
230
  options.signal?.addEventListener("abort", onAbort, { once: true });
222
231
 
@@ -256,7 +256,7 @@ function formatBashFailure(command, res) {
256
256
  const output = String(res.value).trimEnd();
257
257
  const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
258
258
 
259
- return "command failed" + suffix + ": " + command + (output ? "\n" + output : "");
259
+ return "command failed" + suffix + ": " + truncateChars(command, 240, "command").text + (output ? "\n" + output : "");
260
260
  }
261
261
 
262
262
  function markTruncatedOutput(res, text) {
@@ -146,22 +146,45 @@ 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 ?? []));
152
156
  this.collectImages(result, i);
153
157
  }
154
158
 
159
+ parallelBudgetStop(settled) {
160
+ const results = settled.filter(Boolean);
161
+ let images = 0, bytes = 0, labelChars = 0;
162
+ for (const [i, result] of settled.entries()) {
163
+ let imageSeq = 0;
164
+ for (const block of result?.content ?? []) if (block.type === "image" && isString(block.data)) {
165
+ images++;
166
+ bytes += Buffer.byteLength(block.data, "base64");
167
+ labelChars += ("program " + (i + 1) + " image " + (++imageSeq)).length + 1;
168
+ }
169
+ }
170
+ let kind;
171
+ if (images > 16 || bytes > 20 * 1024 * 1024) kind = "image";
172
+ else if (results.some(result => result.details?.returnTruncated) || programBatchText(results, this.programs.length).length + labelChars > this.config.maxReturnChars) kind = "output";
173
+ 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";
174
+ return kind ? "batch " + kind + " budget exceeded; completed commits remain" : "";
175
+ }
176
+
155
177
  async runParallel() {
156
178
  const limit = Math.min(this.programs.length, MAX_PARALLEL_PROGRAMS);
157
179
  const settled = Array.from({ length: this.programs.length });
158
180
  let next = 0;
159
181
 
160
182
  await Promise.all(Array.from({length: limit}, async () => {
161
- while (next < this.programs.length && !this.combined.aborted && performance.now() < this.deadline) {
183
+ while (next < this.programs.length && !this.stopped && !this.combined.aborted && performance.now() < this.deadline) {
162
184
  const i = next++;
163
185
  settled[i] = await this.runOne(this.programs[i], i);
164
186
  this.live[i] = [];
187
+ this.stopped ||= this.parallelBudgetStop(settled);
165
188
  }
166
189
  }));
167
190
 
@@ -170,7 +193,7 @@ class ProgramBatch {
170
193
  this.takeSettled(settled[i], i);
171
194
  }
172
195
 
173
- if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain";
196
+ if ((!this.stopped && settled.includes(undefined)) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
174
197
  }
175
198
 
176
199
  sequentialStop(result, i) {
@@ -185,14 +208,15 @@ class ProgramBatch {
185
208
 
186
209
  if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
187
210
 
188
- if (this.combined.aborted || performance.now() >= this.deadline) stopped ||= "batch deadline or cancellation; earlier commits remain";
211
+ // The deadline explains a killed program better than "program N failed".
212
+ if (this.combined.aborted || performance.now() >= this.deadline) stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
189
213
 
190
214
  return stopped;
191
215
  }
192
216
 
193
217
  async runSequential() {
194
218
  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; }
219
+ if (this.combined.aborted || performance.now() >= this.deadline) { this.stopped = "deadline or cancellation; remaining programs did not run" + this.deadlineNote(); break; }
196
220
 
197
221
  const result = await this.runOne(program, i);
198
222
  this.live[i] = [];
@@ -217,6 +241,8 @@ class ProgramBatch {
217
241
  const failed = this.results.filter(result => result.details?.ok === false).length;
218
242
  const bounded = this.boundedText(failed);
219
243
  const content = [{type:"text",text:bounded.text}];
244
+ const logs = this.results.flatMap(result => result.details?.logs ?? []);
245
+ const logLimit = this.config.maxLogLines ?? 100;
220
246
  this.images.forEach((image,i) => content.push({type:"text",text:this.imageLabels[i]},image));
221
247
 
222
248
  // Return a typed stop report instead of throwing away earlier results/images.
@@ -225,7 +251,7 @@ class ProgramBatch {
225
251
  programs:this.results,attempted:this.results.length,total:this.programs.length,stopped:this.stopped,parallel:this.parallel,
226
252
  result:bounded.truncated ? bounded.text : this.results.map(result=>result.details?.result),
227
253
  returnTruncated:bounded.truncated || this.results.some(result=>result.details?.returnTruncated),
228
- logTruncated:this.results.some(result=>result.details?.logTruncated),logs:this.results.flatMap(result=>result.details?.logs ?? []),trace:this.trace,mutations:mutationTotals(this.results)}};
254
+ logTruncated:logs.length > logLimit || this.results.some(result=>result.details?.logTruncated),logs:logs.slice(0,logLimit),trace:this.trace,mutations:mutationTotals(this.results)}};
229
255
  }
230
256
 
231
257
  async run() {
@@ -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,15 +1,15 @@
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
+ Path-only ≤160 lines AND 8192 characters (UTF-16); larger: read(path,{offset:1,limit:80}), about, or complete:true (whole file ≤31744 chars). Large JSONL: bounded bash parser.
5
+ read({path,json:selector}) → parsed JSON: ".field", ".a[0:3]", ".a.length", quoted keys, true; ≤16 MiB, no jq. {status:"too_large",keys|length} → narrow selector.
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.
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
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.
13
+ programs:[{code?,file?,data?}] inherits code OR file and data. Entries override source/data; mergeData:true shallow-merges objects (entry keys win).
14
+ Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known reads/checks (Promise.all) + edits/verification in ONE call; split for new decisions.
15
15
  `;
@@ -6,10 +6,12 @@ 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
 
12
- const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
13
+ const ABORT_MESSAGE = "supernova aborted";
14
+ const TIMEOUT_MESSAGE = "supernova timed out: increase the outer timeoutMs (and any shorter bash timeoutMs), or split the program; sleeps count toward the deadline";
13
15
 
14
16
  const MEMORY_POLL_MS = 50;
15
17
 
@@ -280,6 +282,7 @@ class GuestRun {
280
282
  this.hostError = undefined;
281
283
  this.notifyingHost = false;
282
284
  this.aborting = false;
285
+ this.abortOutcome = undefined;
283
286
  this.pending = new Set();
284
287
  this.inputController = new AbortController();
285
288
  this.rpcCount = 0;
@@ -319,15 +322,18 @@ class GuestRun {
319
322
  try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
320
323
  }
321
324
 
322
- abort() {
325
+ abort(timedOut = false) {
323
326
  if (this.finished || this.aborting) return;
324
327
  this.aborting = true;
328
+ this.accepting = false;
329
+ this.abortOutcome = this.fail((timedOut ? TIMEOUT_MESSAGE : ABORT_MESSAGE) + " (ran " + this.wall() + "ms of " + this.timeoutMs + "ms)");
325
330
  this.cancelHost();
326
331
 
327
- try { this.onTimeout?.(); } catch {}
332
+ if (timedOut) { try { this.onTimeout?.(); } catch {} }
328
333
 
329
- this.aborting = false;
330
- this.finish(this.fail(ABORT_MESSAGE));
334
+ // Stop the guest immediately, then use the bounded host drain to retain
335
+ // shell diagnostics and wait for process-tree termination before returning.
336
+ void this.complete(this.abortOutcome);
331
337
  }
332
338
 
333
339
  postResult(message) {
@@ -364,6 +370,10 @@ class GuestRun {
364
370
  void killWorker(this.handle);
365
371
  await this.drainPending(outcome);
366
372
  if (this.finished) return;
373
+ if (this.abortOutcome) {
374
+ const diagnostic = this.hostError && this.hostError !== "aborted" ? "\n" + this.hostError : "";
375
+ outcome = this.fail(this.abortOutcome.error + diagnostic);
376
+ }
367
377
  this.finish(outcome.ok && this.hostError ? this.fail(this.hostError) : outcome);
368
378
  }
369
379
 
@@ -434,13 +444,20 @@ class GuestRun {
434
444
  if (!this.code.trim()) { this.finish(this.fail("code must be a non-empty string; no commands ran")); return false; }
435
445
 
436
446
  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; }
447
+ catch (error) {
448
+ const guidance = this.file === undefined
449
+ ? " Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args})."
450
+ : " Fix " + this.file + " and re-run.";
451
+
452
+ this.finish(this.fail("JavaScript syntax error" + (this.file === undefined ? "" : " in " + this.file) + ": " + error.message + "; no commands ran." + guidance + errorContext(this.code, error)));
453
+ return false;
454
+ }
438
455
 
439
456
  return true;
440
457
  }
441
458
 
442
459
  async attachWorker() {
443
- if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
460
+ if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
444
461
  this.handle = acquireWorker(this.config);
445
462
  await this.handle.ready;
446
463
  if (this.finished || this.signal?.aborted) { this.abort(); return false; }
@@ -451,7 +468,7 @@ class GuestRun {
451
468
  this.handle.worker.on("message", this.onMessage);
452
469
  this.handle.worker.on("error", this.onError);
453
470
  this.handle.worker.on("exit", this.onExit);
454
- if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
471
+ if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
455
472
  this.available = available;
456
473
 
457
474
  return true;
@@ -480,7 +497,7 @@ class GuestRun {
480
497
  start() {
481
498
  return new Promise((resolve) => {
482
499
  this.resolve = resolve;
483
- this.timer = setTimeout(() => this.abort(), Math.min(this.timeoutMs, 2147483647));
500
+ this.timer = setTimeout(() => this.abort(true), Math.min(this.timeoutMs, 2147483647));
484
501
  this.memTimer = setInterval(() => {
485
502
  const now = rssBytes();
486
503
 
@@ -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
+ }