pi-supernova 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -196
- package/{CHANGELOG.md → docs/CHANGELOG.md} +44 -1
- package/index.js +81 -68
- package/package.json +12 -31
- package/{catalog.js → src/bridge/catalog.js} +7 -5
- package/{host-bridge.js → src/bridge/host-bridge.js} +232 -87
- package/src/bridge/native-tools.js +155 -0
- package/src/bridge/pi-extension.ts +2 -0
- package/{config.js → src/config/config.js} +1 -1
- package/{evidence.js → src/context/evidence.js} +23 -12
- package/{outline.js → src/context/outline.js} +1 -1
- package/{repo-index.js → src/context/repo-index.js} +11 -9
- package/{search.js → src/context/search.js} +34 -1
- package/{snap.js → src/context/snap.js} +51 -31
- package/{surface.js → src/context/surface.js} +1 -1
- package/{diff.js → src/fs/diff.js} +7 -5
- package/{patch.js → src/fs/patch.js} +1 -1
- package/{vfs.js → src/fs/vfs.js} +55 -14
- package/{workspace.js → src/fs/workspace.js} +22 -6
- package/{bottleneck.js → src/output/bottleneck.js} +23 -6
- package/{format.js → src/output/format.js} +20 -1
- package/{guest-worker.js → src/runtime/guest-worker.js} +90 -21
- package/{parallel.js → src/runtime/parallel.js} +68 -1
- package/{runtime.js → src/runtime/runtime.js} +14 -5
- package/{omp-frame.js → src/ui/omp-frame.js} +1 -1
- package/{render-measure.js → src/ui/render-measure.js} +27 -1
- package/{render.js → src/ui/render.js} +42 -20
- /package/{config.default.json → src/config/config.default.json} +0 -0
- /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
- /package/{ledger.js → src/context/ledger.js} +0 -0
- /package/{check.js → src/fs/check.js} +0 -0
- /package/{decode.js → src/shared/decode.js} +0 -0
package/{vfs.js → src/fs/vfs.js}
RENAMED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { isString } from "
|
|
3
|
+
import { isString } from "../shared/decode.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
6
|
const VFS_CACHE_MAX = 1024;
|
|
7
|
+
// Serialize validation + replacement across Supernova transactions in this host.
|
|
8
|
+
let commitTail = Promise.resolve();
|
|
7
9
|
|
|
8
10
|
export class CausalVfs {
|
|
9
|
-
constructor(onNewFile) {
|
|
11
|
+
constructor(onNewFile, validateWrite) {
|
|
12
|
+
this.validateWrite = validateWrite;
|
|
10
13
|
this.cache = new Map();
|
|
11
14
|
this.overlays = [];
|
|
15
|
+
this.expected = new Map();
|
|
12
16
|
this.onNewFile = onNewFile;
|
|
13
17
|
this.closed = false;
|
|
14
18
|
this.signal = undefined;
|
|
@@ -34,19 +38,19 @@ export class CausalVfs {
|
|
|
34
38
|
return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
async read(target) {
|
|
41
|
+
async read(target, { preserveRead = false } = {}) {
|
|
38
42
|
const overlay = this.getOverlay(target);
|
|
39
43
|
if (overlay !== undefined) return overlay;
|
|
40
44
|
// External editors and captured tools can change a file between any two reads.
|
|
41
45
|
try {
|
|
42
46
|
const text = await fs.readFile(target, "utf8");
|
|
43
|
-
this.setCache(target, text);
|
|
47
|
+
if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
|
|
44
48
|
return text;
|
|
45
49
|
} catch (err) {
|
|
46
50
|
this.cache.delete(target);
|
|
47
51
|
if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
|
|
48
52
|
if (err.code === "ENOENT") {
|
|
49
|
-
const missing = new Error("no such file: " + target + ' (locate it with
|
|
53
|
+
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
|
|
50
54
|
missing.code = "ENOENT";
|
|
51
55
|
throw missing;
|
|
52
56
|
}
|
|
@@ -63,6 +67,13 @@ export class CausalVfs {
|
|
|
63
67
|
if (err.code !== "ENOENT") throw err;
|
|
64
68
|
}
|
|
65
69
|
this.assertWritable();
|
|
70
|
+
if (this.getOverlay(target) === undefined) {
|
|
71
|
+
let original;
|
|
72
|
+
try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
|
|
73
|
+
catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
|
|
74
|
+
this.expected.set(target, original);
|
|
75
|
+
}
|
|
76
|
+
this.assertWritable();
|
|
66
77
|
if (this.overlays.length) {
|
|
67
78
|
this.overlays.at(-1).set(target, content);
|
|
68
79
|
return { speculative: true };
|
|
@@ -79,7 +90,14 @@ export class CausalVfs {
|
|
|
79
90
|
|
|
80
91
|
/** Stage every file and its backup before replacing any destination. */
|
|
81
92
|
async flush(writes) {
|
|
93
|
+
const work = commitTail.then(() => this.flushWrites(writes));
|
|
94
|
+
commitTail = work.catch(() => {});
|
|
95
|
+
return work;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async flushWrites(writes) {
|
|
82
99
|
const staged = [];
|
|
100
|
+
const targets = new Set();
|
|
83
101
|
const createdDirs = [];
|
|
84
102
|
let failed = false;
|
|
85
103
|
try {
|
|
@@ -94,6 +112,15 @@ export class CausalVfs {
|
|
|
94
112
|
} catch (err) {
|
|
95
113
|
if (err.code !== "ENOENT") throw err;
|
|
96
114
|
}
|
|
115
|
+
await this.validateWrite?.(logicalPath);
|
|
116
|
+
if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
|
|
117
|
+
targets.add(target);
|
|
118
|
+
if (this.expected.has(logicalPath)) {
|
|
119
|
+
const current = stat ? await fs.readFile(target, "utf8") : null;
|
|
120
|
+
if (current !== this.expected.get(logicalPath)) {
|
|
121
|
+
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
97
124
|
const parent = path.dirname(target);
|
|
98
125
|
const missing = [];
|
|
99
126
|
let probe = parent;
|
|
@@ -109,18 +136,27 @@ export class CausalVfs {
|
|
|
109
136
|
const token = ".supernova-" + randomUUID();
|
|
110
137
|
const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
111
138
|
staged.push(entry);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
const replacement = (async () => {
|
|
140
|
+
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
141
|
+
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
142
|
+
})();
|
|
143
|
+
// These touch separate staging files. Settle both before cleanup, even
|
|
144
|
+
// on failure: Promise.all could leave a late backup after rollback.
|
|
145
|
+
const staging = [replacement];
|
|
146
|
+
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
147
|
+
const outcomes = await Promise.allSettled(staging);
|
|
148
|
+
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
149
|
+
if (failure) throw failure.reason;
|
|
117
150
|
}
|
|
118
151
|
for (const entry of staged) {
|
|
119
152
|
this.signal?.throwIfAborted();
|
|
120
153
|
await fs.rename(entry.temporary, entry.target);
|
|
121
154
|
entry.replaced = true;
|
|
122
155
|
}
|
|
123
|
-
for (const entry of staged)
|
|
156
|
+
for (const entry of staged) {
|
|
157
|
+
this.setCache(entry.logicalPath, entry.content);
|
|
158
|
+
this.expected.delete(entry.logicalPath);
|
|
159
|
+
}
|
|
124
160
|
if (staged.length) this.onNewFile?.();
|
|
125
161
|
} catch (error) {
|
|
126
162
|
failed = true;
|
|
@@ -141,8 +177,10 @@ export class CausalVfs {
|
|
|
141
177
|
throw error;
|
|
142
178
|
} finally {
|
|
143
179
|
for (const entry of staged) {
|
|
144
|
-
|
|
145
|
-
|
|
180
|
+
// A successful rename consumed the temporary path. These are known
|
|
181
|
+
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
182
|
+
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
183
|
+
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
146
184
|
}
|
|
147
185
|
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
148
186
|
}
|
|
@@ -163,12 +201,15 @@ export class CausalVfs {
|
|
|
163
201
|
|
|
164
202
|
rollback() {
|
|
165
203
|
const top = this.overlays.pop();
|
|
204
|
+
for (const target of top?.keys() ?? []) {
|
|
205
|
+
if (this.getOverlay(target) === undefined) this.expected.delete(target);
|
|
206
|
+
}
|
|
166
207
|
return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
|
|
167
208
|
}
|
|
168
209
|
|
|
169
210
|
async prepareExternalMutation(name) {
|
|
170
211
|
this.assertWritable();
|
|
171
|
-
if (this.overlays.length > 1) throw new Error(name + " cannot run inside
|
|
212
|
+
if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
|
|
172
213
|
if (!this.overlays.length) return false;
|
|
173
214
|
const pending = this.overlays[0];
|
|
174
215
|
await this.flush(pending);
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { constants } from "node:os";
|
|
5
|
-
import { isString } from "
|
|
5
|
+
import { isString } from "../shared/decode.js";
|
|
6
6
|
|
|
7
7
|
let cachedCwd = null;
|
|
8
8
|
let cachedResolvedCwd = null;
|
|
@@ -56,7 +56,7 @@ export function isTestPath(filePath) {
|
|
|
56
56
|
return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
|
|
59
|
+
export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
|
|
60
60
|
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
61
61
|
throw new Error(`${opName} requires path`);
|
|
62
62
|
}
|
|
@@ -71,7 +71,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
|
|
|
71
71
|
realRoot = await fs.realpath(resolvedCwd);
|
|
72
72
|
realRoots.set(resolvedCwd, realRoot);
|
|
73
73
|
}
|
|
74
|
-
let probe = realNearest.get(target);
|
|
74
|
+
let probe = fresh ? undefined : realNearest.get(target);
|
|
75
75
|
if (!probe) {
|
|
76
76
|
probe = await realpathNearest(target);
|
|
77
77
|
if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
|
|
@@ -88,7 +88,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
88
88
|
const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
|
|
89
89
|
return new Promise((resolve, reject) => {
|
|
90
90
|
const child = spawn(argv[0], argv.slice(1), {
|
|
91
|
-
cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
|
|
91
|
+
cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
|
|
92
92
|
});
|
|
93
93
|
let stdout = "";
|
|
94
94
|
let stderr = "";
|
|
@@ -105,6 +105,13 @@ export async function runCommand(argv, options = {}) {
|
|
|
105
105
|
if (settled) return;
|
|
106
106
|
settled = true;
|
|
107
107
|
cleanup();
|
|
108
|
+
// Keep bounded diagnostic output when a command times out or is cancelled.
|
|
109
|
+
error.stdout = stdout;
|
|
110
|
+
error.stderr = stderr;
|
|
111
|
+
error.outputTruncated = outputTruncated;
|
|
112
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
|
|
113
|
+
if (output) error.message += "\n" + output;
|
|
114
|
+
if (outputTruncated) error.message += "\n[output truncated]";
|
|
108
115
|
reject(error);
|
|
109
116
|
};
|
|
110
117
|
const signalTree = signal => {
|
|
@@ -124,7 +131,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
124
131
|
escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
|
|
125
132
|
};
|
|
126
133
|
const onAbort = () => terminate(new Error("aborted"));
|
|
127
|
-
const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + argv.join(" "))), timeoutMs);
|
|
134
|
+
const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
|
|
128
135
|
const append = (current, chunk) => {
|
|
129
136
|
const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
|
|
130
137
|
if (chunk.length > remaining) outputTruncated = true;
|
|
@@ -136,7 +143,16 @@ export async function runCommand(argv, options = {}) {
|
|
|
136
143
|
child.stderr.on("data", chunk => { stderr = append(stderr, chunk); });
|
|
137
144
|
child.on("error", fail);
|
|
138
145
|
child.on("close", (code, signal) => {
|
|
139
|
-
if (settled
|
|
146
|
+
if (settled) return;
|
|
147
|
+
if (terminationError) {
|
|
148
|
+
// A closed pipe alone says nothing about descendants. Only ESRCH proves
|
|
149
|
+
// the owned POSIX group is gone; otherwise retain the escalation timer.
|
|
150
|
+
if (process.platform !== "win32" && child.pid) {
|
|
151
|
+
try { process.kill(-child.pid, 0); }
|
|
152
|
+
catch (error) { if (error.code === "ESRCH") fail(terminationError); }
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
140
156
|
settled = true;
|
|
141
157
|
cleanup();
|
|
142
158
|
resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { isString, isObject } from "
|
|
5
|
-
import { truncateChars,
|
|
4
|
+
import { isString, isObject } from "../shared/decode.js";
|
|
5
|
+
import { truncateChars, formatReturn } from "./format.js";
|
|
6
6
|
|
|
7
7
|
function json(value) {
|
|
8
8
|
try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
|
|
@@ -80,12 +80,15 @@ export function packageHostResult(raw, config) {
|
|
|
80
80
|
const text = batch ? "" : extractRawString(raw);
|
|
81
81
|
const capped = truncateChars(text, maxChars, "host-result");
|
|
82
82
|
let truncated = capped.truncated || details?.outputTruncated === true;
|
|
83
|
-
const
|
|
83
|
+
const image = raw?.content?.find(part => part?.type === "image");
|
|
84
|
+
const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
|
|
84
85
|
if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
|
|
85
86
|
if (batch) {
|
|
87
|
+
result.itemErrors = (details.itemErrors ?? []).map(error => error == null ? null : truncateChars(String(error), Math.max(1, Math.floor(maxChars / batch.length)), "error").text);
|
|
86
88
|
let remaining = maxChars;
|
|
87
89
|
result.items = batch.map((item, index) => {
|
|
88
|
-
|
|
90
|
+
if (item?.type === "image") return item;
|
|
91
|
+
const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
|
|
89
92
|
remaining -= bounded.text.length;
|
|
90
93
|
truncated ||= bounded.truncated;
|
|
91
94
|
return bounded.text;
|
|
@@ -111,7 +114,21 @@ export function packageHostResult(raw, config) {
|
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
export function packageFinalReturn(value, logs, config) {
|
|
114
|
-
const
|
|
117
|
+
const images = [];
|
|
118
|
+
let imageBytes = 0;
|
|
119
|
+
const collect = input => {
|
|
120
|
+
if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
|
|
121
|
+
imageBytes += Buffer.byteLength(input.data, "base64");
|
|
122
|
+
if (images.length >= 16 || imageBytes > 20 * 1024 * 1024) throw new Error("returned images exceed 16 attachments or 20 MiB; return fewer or smaller images");
|
|
123
|
+
images.push({ type: "image", data: input.data, mimeType: input.mimeType });
|
|
124
|
+
return `[image ${images.length}: ${input.mimeType}]`;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(input)) return input.map(collect);
|
|
127
|
+
if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collect(child)]));
|
|
128
|
+
return input;
|
|
129
|
+
};
|
|
130
|
+
value = collect(value);
|
|
131
|
+
const serialized = truncateChars(formatReturn(value), config.maxReturnChars ?? 32000, "return");
|
|
115
132
|
const maxLines = config.maxLogLines ?? 100;
|
|
116
133
|
let logTruncated = logs.length > maxLines;
|
|
117
134
|
const clipped = logs.slice(0, maxLines).map(line => {
|
|
@@ -120,5 +137,5 @@ export function packageFinalReturn(value, logs, config) {
|
|
|
120
137
|
return result.text;
|
|
121
138
|
});
|
|
122
139
|
return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
|
|
123
|
-
returnTruncated: serialized.truncated, logs: clipped, logTruncated };
|
|
140
|
+
returnTruncated: serialized.truncated, logs: clipped, logTruncated, images };
|
|
124
141
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isString, isObject } from "
|
|
1
|
+
import { isString, isObject } from "../shared/decode.js";
|
|
2
2
|
|
|
3
3
|
export function truncateChars(text, maxChars, label = "value") {
|
|
4
4
|
const normalized = isString(text) ? text : String(text ?? "");
|
|
@@ -43,6 +43,25 @@ function tailStartIndex(text, tail) {
|
|
|
43
43
|
return code >= 0xdc00 && code <= 0xdfff ? start + 1 : start;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Unicode mode matches lone surrogate code points, not valid UTF-16 pairs.
|
|
47
|
+
const UNPAIRED_SURROGATE = /[\uD800-\uDFFF]/u;
|
|
48
|
+
|
|
49
|
+
function hasWellFormedStrings(values) {
|
|
50
|
+
for (const value of values) if (!isString(value) || UNPAIRED_SURROGATE.test(value)) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Lossless framing for source arrays, not string escaping or source compression. */
|
|
55
|
+
export function formatReturn(value) {
|
|
56
|
+
if (isString(value)) return value;
|
|
57
|
+
if (Array.isArray(value) && value.length && hasWellFormedStrings(value) && value.some(text => text.includes("\n"))) {
|
|
58
|
+
const raw = "strings[" + value.length + "]\n" + value.map((text, i) => "[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
|
|
59
|
+
const escapedSize = value.reduce((sum, text) => sum + JSON.stringify(text).length, value.length + 1);
|
|
60
|
+
if (raw.length < escapedSize) return raw;
|
|
61
|
+
}
|
|
62
|
+
return formatValue(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
|
|
47
66
|
const FORMAT_WIDTH = 120;
|
|
48
67
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { parentPort } from "node:worker_threads";
|
|
2
|
-
import {
|
|
3
|
-
import { isString, isObject, toPlain } from "
|
|
4
|
-
import { truncateChars } from "
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { isString, isObject, isFunction, toPlain } from "../shared/decode.js";
|
|
4
|
+
import { truncateChars } from "../output/format.js";
|
|
5
5
|
|
|
6
6
|
// Guest programs run here, off the host thread. The host can terminate() this
|
|
7
7
|
// worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
|
|
8
8
|
// code cannot take the harness down with it.
|
|
9
9
|
|
|
10
10
|
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
|
|
11
|
-
const PARAMS = ["
|
|
11
|
+
const PARAMS = ["console", "read", "edit", "write", "bash"];
|
|
12
12
|
const BODY_LINE_OFFSET = 2;
|
|
13
13
|
/** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
|
|
14
14
|
function guestLocation(err) {
|
|
@@ -67,9 +67,15 @@ function leanEnvelope(res) {
|
|
|
67
67
|
return res;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function buildGuestApi(available, batchRead, runId) {
|
|
70
|
+
function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
71
71
|
const rpc = (method, args) => callRpc(runId, method, args);
|
|
72
72
|
const availableSet = new Set(available);
|
|
73
|
+
const checkpointScope = new AsyncLocalStorage();
|
|
74
|
+
let checkpoint = null;
|
|
75
|
+
const assertScope = () => {
|
|
76
|
+
const token = checkpointScope.getStore();
|
|
77
|
+
if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
|
|
78
|
+
};
|
|
73
79
|
const nova = {
|
|
74
80
|
search: (query, limit) => rpc("search", [query, limit]),
|
|
75
81
|
describe: (name) => rpc("describe", [name]),
|
|
@@ -85,15 +91,23 @@ function buildGuestApi(available, batchRead, runId) {
|
|
|
85
91
|
return results;
|
|
86
92
|
},
|
|
87
93
|
async speculate(fn) {
|
|
88
|
-
|
|
94
|
+
if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
|
|
95
|
+
const token = {};
|
|
96
|
+
checkpoint = token;
|
|
97
|
+
let began = false;
|
|
89
98
|
try {
|
|
90
|
-
|
|
99
|
+
flushReads();
|
|
100
|
+
await rpc("speculateBegin", []);
|
|
101
|
+
began = true;
|
|
102
|
+
const value = await checkpointScope.run(token, fn);
|
|
103
|
+
flushReads();
|
|
91
104
|
await rpc("speculateCommit", []);
|
|
92
105
|
return { ok: true, committed: true, value };
|
|
93
106
|
} catch (err) {
|
|
94
|
-
|
|
107
|
+
flushReads();
|
|
108
|
+
if (began) await rpc("speculateRollback", []);
|
|
95
109
|
return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
|
|
96
|
-
}
|
|
110
|
+
} finally { checkpoint = null; }
|
|
97
111
|
},
|
|
98
112
|
surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
|
|
99
113
|
evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
|
|
@@ -101,24 +115,80 @@ function buildGuestApi(available, batchRead, runId) {
|
|
|
101
115
|
has: (name) => availableSet.has(name),
|
|
102
116
|
};
|
|
103
117
|
|
|
104
|
-
//
|
|
105
|
-
|
|
118
|
+
// Coalesce already-started compatible reads without rewriting JS control flow.
|
|
119
|
+
let queuedReads = [];
|
|
120
|
+
function flushReads() {
|
|
121
|
+
const pending = queuedReads;
|
|
122
|
+
queuedReads = [];
|
|
123
|
+
for (let start = 0; start < pending.length; start += 64) {
|
|
124
|
+
const wave = pending.slice(start, start + 64);
|
|
125
|
+
const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
|
|
126
|
+
const run = wave.length === 1
|
|
127
|
+
? nova.call("read", wave[0].args).then(res => ({ values: [unwrapValue(res)], errors: [] }))
|
|
128
|
+
: nova.call("read", args).then(res => { unwrapValue(res); return { values: res.items, errors: res.itemErrors ?? [] }; });
|
|
129
|
+
void run.then(({ values, errors }) => {
|
|
130
|
+
if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
|
|
131
|
+
for (let i = 0; i < wave.length; i++) {
|
|
132
|
+
const value = values[i];
|
|
133
|
+
if (errors[i]) wave[i].reject(new Error(errors[i]));
|
|
134
|
+
else wave[i].resolve(value);
|
|
135
|
+
}
|
|
136
|
+
}).catch(error => { for (const job of wave) job.reject(error); });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const invoke = (name, args) => { assertScope(); flushReads(); return nova.call(name, args); };
|
|
140
|
+
const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
|
|
141
|
+
? { ...p, path: p.path ?? p.query }
|
|
142
|
+
: isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
|
|
106
143
|
const read = async (p, a, b) => {
|
|
144
|
+
assertScope();
|
|
145
|
+
const args = readArgs(p, a, b);
|
|
146
|
+
const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
|
|
147
|
+
p = args.path;
|
|
148
|
+
if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
|
|
149
|
+
if (args.outline) return unwrapJsonValue(await invoke("surface", args));
|
|
107
150
|
if (Array.isArray(p)) {
|
|
108
|
-
if (
|
|
109
|
-
|
|
151
|
+
if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
152
|
+
if (p.some(item => !isString(item) || !item.trim())) throw new Error("read paths must be non-empty strings");
|
|
153
|
+
const readEach = () => Promise.all(p.map(async item => {
|
|
154
|
+
try { return await read({ ...args, path: item }); }
|
|
155
|
+
catch (error) { return `[read error: ${item}] ${error.message}`; }
|
|
156
|
+
}));
|
|
157
|
+
if (!batchRead || args.resolve) return readEach();
|
|
158
|
+
const res = await invoke("read", args);
|
|
110
159
|
unwrapValue(res);
|
|
111
160
|
if (Array.isArray(res?.items)) return res.items;
|
|
112
161
|
// Captured host executor without batch support: fan out.
|
|
113
|
-
return
|
|
162
|
+
return readEach();
|
|
114
163
|
}
|
|
115
|
-
return
|
|
164
|
+
if (!batchRead) return args.resolve ? unwrapJsonValue(await invoke("read", args)) : unwrapValue(await invoke("read", args));
|
|
165
|
+
const key = JSON.stringify({ ...args, path: undefined });
|
|
166
|
+
if (queuedReads.length && queuedReads[0].key !== key) flushReads();
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
queuedReads.push({ args, key, resolve, reject });
|
|
169
|
+
if (queuedReads.length === 1) queueMicrotask(flushReads);
|
|
170
|
+
}).then(value => args.resolve ? JSON.parse(value) : value);
|
|
171
|
+
};
|
|
172
|
+
const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
|
|
173
|
+
const edit = async (p, oldText, newText) => {
|
|
174
|
+
if (isFunction(p)) return nova.speculate(p);
|
|
175
|
+
const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
|
|
176
|
+
return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
|
|
116
177
|
};
|
|
117
|
-
const write = async (p, content) => unwrapValue(await nova.call("write", { path: p, content }));
|
|
118
|
-
const edit = async (p, oldText, newText) => unwrapValue(await nova.call("edit", { path: p, oldText, newText }));
|
|
119
178
|
const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
|
|
120
179
|
const bash = async (command, opts) => {
|
|
121
|
-
const
|
|
180
|
+
const args = isObject(command) ? { ...command } : { command, ...opts };
|
|
181
|
+
if (args.args !== undefined) {
|
|
182
|
+
if (!isString(args.command) || !Array.isArray(args.args) || args.args.some(arg => !isString(arg))) throw new Error("bash argv requires a command string and an array of string args");
|
|
183
|
+
if (nativeArgv) args._directArgv = true;
|
|
184
|
+
else {
|
|
185
|
+
delete args._directArgv;
|
|
186
|
+
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
command = args.command;
|
|
190
|
+
if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
|
|
191
|
+
const res = await invoke("bash", args);
|
|
122
192
|
if (res?.ok === false) {
|
|
123
193
|
let exitCode;
|
|
124
194
|
try {
|
|
@@ -188,12 +258,11 @@ async function handleRun(msg) {
|
|
|
188
258
|
postFailure(runId, err);
|
|
189
259
|
return;
|
|
190
260
|
}
|
|
191
|
-
const api = buildGuestApi(available, batchRead, runId);
|
|
261
|
+
const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
|
|
192
262
|
const scopedConsole = makeConsole(runId, limits);
|
|
193
263
|
try {
|
|
194
264
|
const value = await compiled.fn(
|
|
195
|
-
api.
|
|
196
|
-
api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
|
|
265
|
+
scopedConsole, api.read, api.edit, api.write, api.bash,
|
|
197
266
|
);
|
|
198
267
|
if (runId !== activeRunId) return;
|
|
199
268
|
let plain;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isFunction } from "
|
|
1
|
+
import { isFunction } from "../shared/decode.js";
|
|
2
2
|
|
|
3
3
|
const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
|
|
4
4
|
const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
|
|
@@ -19,6 +19,73 @@ export function isMutatingTool(name, config = {}, args = {}, definition) {
|
|
|
19
19
|
return !READ_ONLY_TOOLS.has(name);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** FIFO read waves with mutation barriers; callers keep independent promises. */
|
|
23
|
+
export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
|
|
24
|
+
if (!Number.isInteger(maxParallelReads) || maxParallelReads < 1) throw new Error("maxParallelReads must be a positive integer");
|
|
25
|
+
const queue = [];
|
|
26
|
+
const stats = { calls: 0, readWaves: 0, peakParallelReads: 0 };
|
|
27
|
+
let draining = false;
|
|
28
|
+
|
|
29
|
+
async function drain() {
|
|
30
|
+
try {
|
|
31
|
+
while (queue.length) {
|
|
32
|
+
const first = queue.shift();
|
|
33
|
+
if (first.cancelled) continue;
|
|
34
|
+
const wave = [first];
|
|
35
|
+
if (first.name === "read") {
|
|
36
|
+
while (wave.length < maxParallelReads && queue[0]?.name === "read") {
|
|
37
|
+
const next = queue.shift();
|
|
38
|
+
if (!next.cancelled) wave.push(next);
|
|
39
|
+
}
|
|
40
|
+
stats.readWaves++;
|
|
41
|
+
stats.peakParallelReads = Math.max(stats.peakParallelReads, wave.length);
|
|
42
|
+
}
|
|
43
|
+
// Settle each promise separately: one failed read must not discard its
|
|
44
|
+
// siblings or release a write barrier while other reads are still active.
|
|
45
|
+
await Promise.all(wave.map(async job => {
|
|
46
|
+
if (job.cancelled) return;
|
|
47
|
+
job.started = true;
|
|
48
|
+
job.signal?.removeEventListener("abort", job.abort);
|
|
49
|
+
try {
|
|
50
|
+
job.signal?.throwIfAborted();
|
|
51
|
+
const result = await job.run();
|
|
52
|
+
job.signal?.throwIfAborted();
|
|
53
|
+
job.resolve(result);
|
|
54
|
+
} catch (error) { job.reject(error); }
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
} finally { draining = false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
stats,
|
|
62
|
+
schedule(name, run, signal) {
|
|
63
|
+
if (!["read", "edit", "write", "bash"].includes(name) || !isFunction(run)) {
|
|
64
|
+
return Promise.reject(new Error("scheduler requires a native tool and an executor"));
|
|
65
|
+
}
|
|
66
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
67
|
+
stats.calls++;
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const job = { name, run, signal, resolve, reject, started: false, cancelled: false };
|
|
70
|
+
job.abort = () => {
|
|
71
|
+
if (job.started) return;
|
|
72
|
+
job.cancelled = true;
|
|
73
|
+
signal.removeEventListener("abort", job.abort);
|
|
74
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
75
|
+
};
|
|
76
|
+
signal?.addEventListener("abort", job.abort, { once: true });
|
|
77
|
+
queue.push(job);
|
|
78
|
+
if (!draining) {
|
|
79
|
+
draining = true;
|
|
80
|
+
// Pi submits sibling tools in the same turn without model-side code.
|
|
81
|
+
// Collect those submissions before selecting the first read wave.
|
|
82
|
+
queueMicrotask(() => { void drain(); });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
22
89
|
function requireArray(value, name) {
|
|
23
90
|
if (!Array.isArray(value)) throw new TypeError(name + " requires an array");
|
|
24
91
|
return value;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
2
|
import { parse } from "acorn";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
|
-
import { packageFinalReturn } from "
|
|
5
|
-
import {
|
|
4
|
+
import { packageFinalReturn } from "../output/bottleneck.js";
|
|
5
|
+
import { truncateChars } from "../output/format.js";
|
|
6
|
+
import { isFunction, isObject, isString } from "../shared/decode.js";
|
|
6
7
|
|
|
7
8
|
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
8
9
|
const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
|
|
@@ -52,7 +53,10 @@ function prepareProgram(code) {
|
|
|
52
53
|
|
|
53
54
|
function spawnWorker(config) {
|
|
54
55
|
const maxHeapMb = config.maxHeapMb ?? 512;
|
|
55
|
-
|
|
56
|
+
// An inline bootstrap accepts inherited --input-type from stdin/eval SDK hosts.
|
|
57
|
+
// Keep Node's automatic flag inheritance: explicitly copying execArgv can
|
|
58
|
+
// reintroduce process-only V8 flags that Worker rejects under node --test.
|
|
59
|
+
const worker = new Worker("import(" + JSON.stringify(WORKER_URL.href) + ")", { eval: true, resourceLimits: { maxOldGenerationSizeMb: maxHeapMb } });
|
|
56
60
|
const handle = { worker, maxHeapMb, dead: false, ready: null };
|
|
57
61
|
// This listener also owns errors between readiness and a run's listeners.
|
|
58
62
|
worker.on("error", () => { handle.dead = true; });
|
|
@@ -111,6 +115,10 @@ export function warmGuestWorker(config = {}) {
|
|
|
111
115
|
return handle.ready;
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
export function stopWarmGuestWorker() {
|
|
119
|
+
return killWorker(idleWorker);
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
const RPC_METHODS = {
|
|
115
123
|
call: (nova, args) => nova.call(args[0], args[1]),
|
|
116
124
|
callMany: async (nova, args) => {
|
|
@@ -128,7 +136,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
128
136
|
const started = performance.now();
|
|
129
137
|
const wall = () => Math.round(performance.now() - started);
|
|
130
138
|
const logs = [];
|
|
131
|
-
const fail = (error) => ({ ok: false, error, logs, logTruncated, wallMs: wall() });
|
|
139
|
+
const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
|
|
132
140
|
let logTruncated = false;
|
|
133
141
|
if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
|
|
134
142
|
if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters");
|
|
@@ -234,7 +242,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
234
242
|
try {
|
|
235
243
|
const packed = packageFinalReturn(msg.value, logs, config);
|
|
236
244
|
void complete({ ok: true, result: packed.returnValue, resultText: packed.returnText,
|
|
237
|
-
returnTruncated: packed.returnTruncated, undefinedReturn: msg.undefinedReturn === true,
|
|
245
|
+
returnTruncated: packed.returnTruncated, images: packed.images, undefinedReturn: msg.undefinedReturn === true,
|
|
238
246
|
logs: packed.logs, logTruncated: logTruncated || packed.logTruncated });
|
|
239
247
|
} catch (err) {
|
|
240
248
|
void complete(fail(err.message));
|
|
@@ -260,6 +268,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
260
268
|
if (wall() >= timeoutMs) return abort();
|
|
261
269
|
handle.worker.postMessage({ op: "run", runId, prepared, available,
|
|
262
270
|
batchRead: nova.batchRead !== false,
|
|
271
|
+
nativeArgv: nova.nativeArgv === true,
|
|
263
272
|
limits: { maxLogLines: config.maxLogLines ?? 100, maxLogLineChars: config.maxLogLineChars ?? 4096 } });
|
|
264
273
|
} catch (err) {
|
|
265
274
|
cancelHost();
|