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 +110 -48
- package/docs/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/src/adapters/bash.js +17 -4
- package/src/adapters/read.js +23 -4
- package/src/adapters/write.js +14 -5
- package/src/contract/bash.js +21 -1
- package/src/contract/edit.js +21 -3
- package/src/contract/read.js +15 -0
- package/src/fs/check.js +2 -1
- package/src/fs/text-ops.js +28 -18
- package/src/fs/vfs.js +20 -6
- package/src/fs/workspace.js +11 -2
- package/src/runtime/guest-worker.js +1 -1
- package/src/runtime/program-batch.js +31 -5
- package/src/runtime/program-file.js +4 -1
- package/src/runtime/reference.js +9 -9
- package/src/runtime/runtime.js +26 -9
- package/src/shared/syntax-context.js +31 -0
- package/src/shared/utf8.js +17 -0
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
|
-
##
|
|
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.
|
|
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
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- **
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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.
|
|
82
|
-
omp install npm:pi-supernova@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)
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
package/src/adapters/bash.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
1
2
|
import { isString } from "../shared/decode.js";
|
|
2
|
-
import {
|
|
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 =
|
|
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:
|
|
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)
|
|
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 }],
|
package/src/adapters/read.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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)];
|
package/src/adapters/write.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 };
|
package/src/contract/bash.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/contract/edit.js
CHANGED
|
@@ -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 (
|
|
43
|
+
if (Array.isArray(oldText)) return { path: p, edits: oldText };
|
|
44
44
|
|
|
45
|
-
|
|
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
|
}
|
package/src/contract/read.js
CHANGED
|
@@ -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.
|
|
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) {
|
package/src/fs/text-ops.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
}
|
package/src/fs/workspace.js
CHANGED
|
@@ -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(
|
|
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 =>
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/src/runtime/reference.js
CHANGED
|
@@ -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
|
|
3
|
-
read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16
|
|
4
|
-
Path-only
|
|
5
|
-
read({path,json:selector}) → parsed JSON
|
|
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
|
|
10
|
-
edit(view,text) replaces
|
|
11
|
-
bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv; bounded output, nonzero throws; inherits
|
|
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
|
|
14
|
-
Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known
|
|
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
|
`;
|
package/src/runtime/runtime.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
330
|
-
|
|
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) {
|
|
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
|
+
}
|