pi-supernova 0.3.2 → 0.4.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 +134 -15
- package/docs/CHANGELOG.md +28 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -38
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +271 -6
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +24 -0
- package/src/context/repo-index.js +62 -0
- package/src/context/search.js +45 -0
- package/src/context/snap.js +80 -4
- package/src/context/surface.js +25 -0
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +87 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +99 -5
- package/src/fs/workspace.js +35 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +136 -13
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +25 -0
- package/src/runtime/runtime.js +90 -6
- package/src/shared/decode.js +29 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/src/fs/vfs.js
CHANGED
|
@@ -4,6 +4,7 @@ import { isString } from "../shared/decode.js";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
6
|
const VFS_CACHE_MAX = 1024;
|
|
7
|
+
|
|
7
8
|
// Serialize validation + replacement across Supernova transactions in this host.
|
|
8
9
|
let commitTail = Promise.resolve();
|
|
9
10
|
|
|
@@ -16,6 +17,7 @@ export class CausalVfs {
|
|
|
16
17
|
this.onNewFile = onNewFile;
|
|
17
18
|
this.closed = false;
|
|
18
19
|
this.signal = undefined;
|
|
20
|
+
this.mutations = { committed: 0, rolledBack: 0, external: 0, pendingCommits: 0, recoveryFailed: false };
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
assertWritable() {
|
|
@@ -38,60 +40,110 @@ export class CausalVfs {
|
|
|
38
40
|
return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
async read(target, { preserveRead = false } = {}) {
|
|
43
|
+
async read(target, { preserveRead = false, maxBytes } = {}) {
|
|
42
44
|
const overlay = this.getOverlay(target);
|
|
43
|
-
|
|
45
|
+
|
|
46
|
+
if (overlay !== undefined) {
|
|
47
|
+
if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
48
|
+
|
|
49
|
+
return overlay;
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
// External editors and captured tools can change a file between any two reads.
|
|
45
53
|
try {
|
|
46
|
-
|
|
54
|
+
let text;
|
|
55
|
+
|
|
56
|
+
if (maxBytes === undefined) text = await fs.readFile(target, "utf8");
|
|
57
|
+
else {
|
|
58
|
+
const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const stat = await file.stat();
|
|
62
|
+
|
|
63
|
+
if (!stat.isFile()) throw new Error("JSON read requires a regular file: " + target);
|
|
64
|
+
const tooLarge = () => new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
65
|
+
|
|
66
|
+
if (stat.size > maxBytes) throw tooLarge();
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
|
|
70
|
+
for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal: this.signal })) {
|
|
71
|
+
size += chunk.length;
|
|
72
|
+
|
|
73
|
+
if (size > maxBytes) throw tooLarge();
|
|
74
|
+
chunks.push(chunk);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
text = Buffer.concat(chunks).toString("utf8");
|
|
78
|
+
} finally { await file.close(); }
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
|
|
82
|
+
|
|
48
83
|
return text;
|
|
49
84
|
} catch (err) {
|
|
50
85
|
this.cache.delete(target);
|
|
86
|
+
|
|
51
87
|
if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
|
|
88
|
+
|
|
52
89
|
if (err.code === "ENOENT") {
|
|
53
90
|
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
|
|
54
91
|
missing.code = "ENOENT";
|
|
55
92
|
throw missing;
|
|
56
93
|
}
|
|
94
|
+
|
|
57
95
|
throw err;
|
|
58
96
|
}
|
|
59
97
|
}
|
|
60
98
|
|
|
61
99
|
async write(target, content) {
|
|
62
100
|
this.assertWritable();
|
|
101
|
+
|
|
63
102
|
if (!isString(content)) throw new Error("write requires string content");
|
|
103
|
+
|
|
64
104
|
try {
|
|
65
105
|
if ((await fs.stat(target)).isDirectory()) throw new Error("cannot write to a directory: " + target);
|
|
66
106
|
} catch (err) {
|
|
67
107
|
if (err.code !== "ENOENT") throw err;
|
|
68
108
|
}
|
|
109
|
+
|
|
69
110
|
this.assertWritable();
|
|
111
|
+
|
|
70
112
|
if (this.getOverlay(target) === undefined) {
|
|
71
113
|
let original;
|
|
114
|
+
|
|
72
115
|
try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
|
|
73
116
|
catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
|
|
117
|
+
|
|
74
118
|
this.expected.set(target, original);
|
|
75
119
|
}
|
|
120
|
+
|
|
76
121
|
this.assertWritable();
|
|
122
|
+
|
|
77
123
|
if (this.overlays.length) {
|
|
78
124
|
this.overlays.at(-1).set(target, content);
|
|
125
|
+
|
|
79
126
|
return { speculative: true };
|
|
80
127
|
}
|
|
128
|
+
|
|
81
129
|
await this.flush(new Map([[target, content]]));
|
|
130
|
+
|
|
82
131
|
return { speculative: false };
|
|
83
132
|
}
|
|
84
133
|
|
|
85
134
|
begin() {
|
|
86
135
|
this.assertWritable();
|
|
87
136
|
this.overlays.push(new Map());
|
|
137
|
+
|
|
88
138
|
return this.overlays.length;
|
|
89
139
|
}
|
|
90
140
|
|
|
91
141
|
/** Stage every file and its backup before replacing any destination. */
|
|
92
142
|
async flush(writes) {
|
|
93
|
-
|
|
143
|
+
this.mutations.pendingCommits++;
|
|
144
|
+
const work = commitTail.then(() => this.flushWrites(writes)).finally(() => { this.mutations.pendingCommits--; });
|
|
94
145
|
commitTail = work.catch(() => {});
|
|
146
|
+
|
|
95
147
|
return work;
|
|
96
148
|
}
|
|
97
149
|
|
|
@@ -100,30 +152,39 @@ export class CausalVfs {
|
|
|
100
152
|
const targets = new Set();
|
|
101
153
|
const createdDirs = [];
|
|
102
154
|
let failed = false;
|
|
155
|
+
|
|
103
156
|
try {
|
|
104
157
|
for (const [logicalPath, content] of writes) {
|
|
105
158
|
this.signal?.throwIfAborted();
|
|
106
159
|
let target = logicalPath;
|
|
107
160
|
let stat;
|
|
161
|
+
|
|
108
162
|
try {
|
|
109
163
|
target = await fs.realpath(logicalPath);
|
|
110
164
|
stat = await fs.stat(target);
|
|
165
|
+
|
|
111
166
|
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
112
167
|
} catch (err) {
|
|
113
168
|
if (err.code !== "ENOENT") throw err;
|
|
114
169
|
}
|
|
170
|
+
|
|
115
171
|
await this.validateWrite?.(logicalPath);
|
|
172
|
+
|
|
116
173
|
if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
|
|
117
174
|
targets.add(target);
|
|
175
|
+
|
|
118
176
|
if (this.expected.has(logicalPath)) {
|
|
119
177
|
const current = stat ? await fs.readFile(target, "utf8") : null;
|
|
178
|
+
|
|
120
179
|
if (current !== this.expected.get(logicalPath)) {
|
|
121
180
|
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
122
181
|
}
|
|
123
182
|
}
|
|
183
|
+
|
|
124
184
|
const parent = path.dirname(target);
|
|
125
185
|
const missing = [];
|
|
126
186
|
let probe = parent;
|
|
187
|
+
|
|
127
188
|
for (;;) {
|
|
128
189
|
try { await fs.stat(probe); break; } catch (err) {
|
|
129
190
|
if (err.code !== "ENOENT") throw err;
|
|
@@ -131,38 +192,50 @@ export class CausalVfs {
|
|
|
131
192
|
probe = path.dirname(probe);
|
|
132
193
|
}
|
|
133
194
|
}
|
|
195
|
+
|
|
134
196
|
await fs.mkdir(parent, { recursive: true });
|
|
135
197
|
createdDirs.push(...missing.reverse());
|
|
136
198
|
const token = ".supernova-" + randomUUID();
|
|
137
199
|
const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
138
200
|
staged.push(entry);
|
|
201
|
+
|
|
139
202
|
const replacement = (async () => {
|
|
140
203
|
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
204
|
+
|
|
141
205
|
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
142
206
|
})();
|
|
207
|
+
|
|
143
208
|
// These touch separate staging files. Settle both before cleanup, even
|
|
144
209
|
// on failure: Promise.all could leave a late backup after rollback.
|
|
145
210
|
const staging = [replacement];
|
|
211
|
+
|
|
146
212
|
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
147
213
|
const outcomes = await Promise.allSettled(staging);
|
|
148
214
|
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
215
|
+
|
|
149
216
|
if (failure) throw failure.reason;
|
|
150
217
|
}
|
|
218
|
+
|
|
151
219
|
for (const entry of staged) {
|
|
152
220
|
this.signal?.throwIfAborted();
|
|
153
221
|
await fs.rename(entry.temporary, entry.target);
|
|
154
222
|
entry.replaced = true;
|
|
155
223
|
}
|
|
224
|
+
|
|
156
225
|
for (const entry of staged) {
|
|
157
226
|
this.setCache(entry.logicalPath, entry.content);
|
|
158
227
|
this.expected.delete(entry.logicalPath);
|
|
159
228
|
}
|
|
229
|
+
|
|
160
230
|
if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
|
|
231
|
+
this.mutations.committed += staged.length;
|
|
161
232
|
} catch (error) {
|
|
162
233
|
failed = true;
|
|
163
234
|
const recoveryErrors = [];
|
|
235
|
+
|
|
164
236
|
for (const entry of staged.toReversed()) {
|
|
165
237
|
if (!entry.replaced) continue;
|
|
238
|
+
|
|
166
239
|
try {
|
|
167
240
|
if (entry.existed) await fs.rename(entry.backup, entry.target);
|
|
168
241
|
else await fs.unlink(entry.target);
|
|
@@ -172,8 +245,13 @@ export class CausalVfs {
|
|
|
172
245
|
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
173
246
|
}
|
|
174
247
|
}
|
|
248
|
+
|
|
175
249
|
this.invalidateCache();
|
|
250
|
+
|
|
251
|
+
if (recoveryErrors.length) this.mutations.recoveryFailed = true;
|
|
252
|
+
|
|
176
253
|
if (recoveryErrors.length) this.onNewFile?.(null);
|
|
254
|
+
|
|
177
255
|
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
178
256
|
throw error;
|
|
179
257
|
} finally {
|
|
@@ -181,8 +259,10 @@ export class CausalVfs {
|
|
|
181
259
|
// A successful rename consumed the temporary path. These are known
|
|
182
260
|
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
183
261
|
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
262
|
+
|
|
184
263
|
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
185
264
|
}
|
|
265
|
+
|
|
186
266
|
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
187
267
|
}
|
|
188
268
|
}
|
|
@@ -190,32 +270,46 @@ export class CausalVfs {
|
|
|
190
270
|
async commit() {
|
|
191
271
|
if (!this.overlays.length) return { committed: 0, depth: 0 };
|
|
192
272
|
const top = this.overlays.at(-1);
|
|
273
|
+
|
|
193
274
|
if (this.overlays.length > 1) {
|
|
194
275
|
const parent = this.overlays[this.overlays.length - 2];
|
|
276
|
+
|
|
195
277
|
for (const [key, value] of top) parent.set(key, value);
|
|
196
278
|
} else {
|
|
197
279
|
await this.flush(top);
|
|
198
280
|
}
|
|
281
|
+
|
|
199
282
|
this.overlays.pop();
|
|
283
|
+
|
|
200
284
|
return { committed: top.size, depth: this.overlays.length };
|
|
201
285
|
}
|
|
202
286
|
|
|
203
287
|
rollback() {
|
|
204
288
|
const top = this.overlays.pop();
|
|
289
|
+
this.mutations.rolledBack += top?.size ?? 0;
|
|
290
|
+
|
|
205
291
|
for (const target of top?.keys() ?? []) {
|
|
206
292
|
if (this.getOverlay(target) === undefined) this.expected.delete(target);
|
|
207
293
|
}
|
|
294
|
+
|
|
208
295
|
return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
|
|
209
296
|
}
|
|
210
297
|
|
|
211
298
|
async prepareExternalMutation(name) {
|
|
212
299
|
this.assertWritable();
|
|
300
|
+
|
|
213
301
|
if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
|
|
214
|
-
|
|
302
|
+
|
|
303
|
+
if (!this.overlays.length) { this.mutations.external++;
|
|
304
|
+
|
|
305
|
+
return false; }
|
|
306
|
+
|
|
215
307
|
const pending = this.overlays[0];
|
|
216
308
|
await this.flush(pending);
|
|
217
309
|
this.assertWritable();
|
|
218
310
|
this.overlays[0] = new Map();
|
|
311
|
+
this.mutations.external++;
|
|
312
|
+
|
|
219
313
|
return pending.size > 0;
|
|
220
314
|
}
|
|
221
315
|
|
package/src/fs/workspace.js
CHANGED
|
@@ -5,10 +5,14 @@ import { constants } from "node:os";
|
|
|
5
5
|
import { isString } from "../shared/decode.js";
|
|
6
6
|
|
|
7
7
|
let cachedCwd = null;
|
|
8
|
+
|
|
8
9
|
let cachedResolvedCwd = null;
|
|
10
|
+
|
|
9
11
|
// realpath results per program: two syscalls per call otherwise dominate a cached read.
|
|
10
12
|
const realRoots = new Map();
|
|
13
|
+
|
|
11
14
|
const realNearest = new Map();
|
|
15
|
+
|
|
12
16
|
const PATH_CACHE_MAX = 2048;
|
|
13
17
|
|
|
14
18
|
export function clearPathCache() {
|
|
@@ -19,6 +23,7 @@ function getResolvedCwd(cwd) {
|
|
|
19
23
|
if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
|
|
20
24
|
cachedCwd = cwd;
|
|
21
25
|
cachedResolvedCwd = path.resolve(cwd);
|
|
26
|
+
|
|
22
27
|
return cachedResolvedCwd;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -30,12 +35,14 @@ function assertInside(rel, message) {
|
|
|
30
35
|
|
|
31
36
|
async function realpathNearest(target) {
|
|
32
37
|
let probe = target;
|
|
38
|
+
|
|
33
39
|
while (true) {
|
|
34
40
|
try {
|
|
35
41
|
return await fs.realpath(probe);
|
|
36
42
|
} catch (err) {
|
|
37
43
|
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
38
44
|
const parent = path.dirname(probe);
|
|
45
|
+
|
|
39
46
|
if (parent === probe) throw err;
|
|
40
47
|
probe = parent;
|
|
41
48
|
}
|
|
@@ -53,6 +60,7 @@ const TEST_SEGMENTS = new Set(["test", "tests", "__tests__", "spec"]);
|
|
|
53
60
|
export function isTestPath(filePath) {
|
|
54
61
|
const segments = filePath.split(/[\\/]/);
|
|
55
62
|
const base = segments[segments.length - 1];
|
|
63
|
+
|
|
56
64
|
return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
|
|
57
65
|
}
|
|
58
66
|
|
|
@@ -60,25 +68,34 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
|
|
|
60
68
|
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
61
69
|
throw new Error(`${opName} requires path`);
|
|
62
70
|
}
|
|
71
|
+
|
|
63
72
|
if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
|
|
64
73
|
const resolvedCwd = getResolvedCwd(cwd);
|
|
65
74
|
const target = path.resolve(resolvedCwd, inputPath.trim());
|
|
66
75
|
assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
|
|
76
|
+
|
|
67
77
|
if (!allowRoot && target === resolvedCwd) {
|
|
68
78
|
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
69
79
|
}
|
|
80
|
+
|
|
70
81
|
let realRoot = realRoots.get(resolvedCwd);
|
|
82
|
+
|
|
71
83
|
if (!realRoot) {
|
|
72
84
|
realRoot = await fs.realpath(resolvedCwd);
|
|
73
85
|
realRoots.set(resolvedCwd, realRoot);
|
|
74
86
|
}
|
|
87
|
+
|
|
75
88
|
let probe = fresh ? undefined : realNearest.get(target);
|
|
89
|
+
|
|
76
90
|
if (!probe) {
|
|
77
91
|
probe = await realpathNearest(target);
|
|
92
|
+
|
|
78
93
|
if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
|
|
79
94
|
realNearest.set(target, probe);
|
|
80
95
|
}
|
|
96
|
+
|
|
81
97
|
assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
|
|
98
|
+
|
|
82
99
|
return target;
|
|
83
100
|
}
|
|
84
101
|
|
|
@@ -87,21 +104,25 @@ export async function runCommand(argv, options = {}) {
|
|
|
87
104
|
const cwd = options.cwd || process.cwd();
|
|
88
105
|
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
89
106
|
const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
|
|
107
|
+
|
|
90
108
|
return new Promise((resolve, reject) => {
|
|
91
109
|
const child = spawn(argv[0], argv.slice(1), {
|
|
92
110
|
cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
|
|
93
111
|
});
|
|
112
|
+
|
|
94
113
|
let stdout = "";
|
|
95
114
|
let stderr = "";
|
|
96
115
|
let settled = false;
|
|
97
116
|
let outputTruncated = false;
|
|
98
117
|
let terminationError;
|
|
99
118
|
let escalation;
|
|
119
|
+
|
|
100
120
|
const cleanup = () => {
|
|
101
121
|
clearTimeout(timer);
|
|
102
122
|
clearTimeout(escalation);
|
|
103
123
|
options.signal?.removeEventListener("abort", onAbort);
|
|
104
124
|
};
|
|
125
|
+
|
|
105
126
|
const fail = error => {
|
|
106
127
|
if (settled) return;
|
|
107
128
|
settled = true;
|
|
@@ -111,12 +132,16 @@ export async function runCommand(argv, options = {}) {
|
|
|
111
132
|
error.stderr = stderr;
|
|
112
133
|
error.outputTruncated = outputTruncated;
|
|
113
134
|
const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
|
|
135
|
+
|
|
114
136
|
if (output) error.message += "\n" + output;
|
|
137
|
+
|
|
115
138
|
if (outputTruncated) error.message += "\n[output truncated]";
|
|
116
139
|
reject(error);
|
|
117
140
|
};
|
|
141
|
+
|
|
118
142
|
const signalTree = signal => {
|
|
119
143
|
if (!child.pid) return;
|
|
144
|
+
|
|
120
145
|
if (process.platform === "win32") {
|
|
121
146
|
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
|
|
122
147
|
killer.on("error", () => child.kill(signal));
|
|
@@ -124,6 +149,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
124
149
|
try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
|
|
125
150
|
}
|
|
126
151
|
};
|
|
152
|
+
|
|
127
153
|
const terminate = error => {
|
|
128
154
|
if (settled || terminationError) return;
|
|
129
155
|
terminationError = error;
|
|
@@ -131,13 +157,18 @@ export async function runCommand(argv, options = {}) {
|
|
|
131
157
|
// Keep ownership after the direct child exits: descendants may ignore SIGTERM.
|
|
132
158
|
escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
|
|
133
159
|
};
|
|
160
|
+
|
|
134
161
|
const onAbort = () => terminate(new Error("aborted"));
|
|
135
162
|
const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
|
|
163
|
+
|
|
136
164
|
const append = (current, chunk) => {
|
|
137
165
|
const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
|
|
166
|
+
|
|
138
167
|
if (chunk.length > remaining) outputTruncated = true;
|
|
168
|
+
|
|
139
169
|
return remaining ? current + chunk.slice(0, remaining) : current;
|
|
140
170
|
};
|
|
171
|
+
|
|
141
172
|
child.stdout.setEncoding("utf8");
|
|
142
173
|
child.stderr.setEncoding("utf8");
|
|
143
174
|
child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
|
|
@@ -145,6 +176,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
145
176
|
child.on("error", fail);
|
|
146
177
|
child.on("close", (code, signal) => {
|
|
147
178
|
if (settled) return;
|
|
179
|
+
|
|
148
180
|
if (terminationError) {
|
|
149
181
|
// A closed pipe alone says nothing about descendants. Only ESRCH proves
|
|
150
182
|
// the owned POSIX group is gone; otherwise retain the escalation timer.
|
|
@@ -152,13 +184,16 @@ export async function runCommand(argv, options = {}) {
|
|
|
152
184
|
try { process.kill(-child.pid, 0); }
|
|
153
185
|
catch (error) { if (error.code === "ESRCH") fail(terminationError); }
|
|
154
186
|
}
|
|
187
|
+
|
|
155
188
|
return;
|
|
156
189
|
}
|
|
190
|
+
|
|
157
191
|
settled = true;
|
|
158
192
|
cleanup();
|
|
159
193
|
resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
|
|
160
194
|
});
|
|
161
195
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
196
|
+
|
|
162
197
|
if (options.signal?.aborted) onAbort();
|
|
163
198
|
});
|
|
164
199
|
}
|
package/src/output/bottleneck.js
CHANGED
|
@@ -7,61 +7,87 @@ import { truncateChars, formatReturn } from "./format.js";
|
|
|
7
7
|
function json(value) {
|
|
8
8
|
try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
|
|
9
9
|
}
|
|
10
|
+
|
|
10
11
|
function detailsOf(raw) {
|
|
11
12
|
const details = raw?.details;
|
|
13
|
+
|
|
12
14
|
if (!isString(details)) return details;
|
|
15
|
+
|
|
13
16
|
try { return JSON.parse(details); } catch { return details; }
|
|
14
17
|
}
|
|
18
|
+
|
|
15
19
|
export function hostResultFailed(raw) {
|
|
16
20
|
const details = detailsOf(raw);
|
|
21
|
+
|
|
17
22
|
return raw?.isError === true || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
|
|
18
23
|
}
|
|
24
|
+
|
|
19
25
|
function extractRawString(raw) {
|
|
20
26
|
if (raw == null) return "";
|
|
27
|
+
|
|
21
28
|
if (isString(raw)) return raw;
|
|
29
|
+
|
|
22
30
|
if (!isObject(raw)) return String(raw);
|
|
31
|
+
|
|
23
32
|
if (Array.isArray(raw.content)) return raw.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n");
|
|
33
|
+
|
|
24
34
|
if (isString(raw.text)) return raw.text;
|
|
35
|
+
|
|
25
36
|
return json(raw);
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
/** Bound JSON before serialization; preserve small scalar fields such as exitCode. */
|
|
29
40
|
function summarizeDetails(value, budget = 2000) {
|
|
30
41
|
const encoded = json(value);
|
|
42
|
+
|
|
31
43
|
if (encoded.length <= budget) return encoded;
|
|
32
44
|
const snapshot = JSON.parse(encoded);
|
|
45
|
+
|
|
33
46
|
const fit = (input, limit) => {
|
|
34
47
|
const serialized = json(input);
|
|
48
|
+
|
|
35
49
|
if (serialized.length <= limit) return input;
|
|
50
|
+
|
|
36
51
|
if (isString(input)) {
|
|
37
52
|
let low = 0;
|
|
38
53
|
let high = Math.min(input.length, limit);
|
|
54
|
+
|
|
39
55
|
while (low < high) {
|
|
40
56
|
const mid = Math.ceil((low + high) / 2);
|
|
57
|
+
|
|
41
58
|
if (json(truncateChars(input, mid).text).length <= limit) low = mid;
|
|
42
59
|
else high = mid - 1;
|
|
43
60
|
}
|
|
61
|
+
|
|
44
62
|
return truncateChars(input, low).text;
|
|
45
63
|
}
|
|
64
|
+
|
|
46
65
|
if (!isObject(input) && !Array.isArray(input)) return null;
|
|
47
66
|
const out = Array.isArray(input) ? [] : { truncated: true };
|
|
48
67
|
const entries = Object.entries(input);
|
|
68
|
+
|
|
49
69
|
if (!Array.isArray(input)) entries.sort((a, b) => json(a[1]).length - json(b[1]).length);
|
|
70
|
+
|
|
50
71
|
for (const [key, child] of entries) {
|
|
51
72
|
const used = json(out).length;
|
|
52
73
|
const overhead = Array.isArray(out) ? 1 : json(key).length + 2;
|
|
53
74
|
const available = limit - used - overhead;
|
|
75
|
+
|
|
54
76
|
if (available < 4) break;
|
|
55
77
|
const bounded = fit(child, available);
|
|
78
|
+
|
|
56
79
|
if (Array.isArray(out)) out.push(bounded);
|
|
57
80
|
else Object.defineProperty(out, key, { value: bounded, enumerable: true, configurable: true });
|
|
81
|
+
|
|
58
82
|
if (json(out).length > limit) {
|
|
59
83
|
if (Array.isArray(out)) out.pop();
|
|
60
84
|
else delete out[key];
|
|
61
85
|
}
|
|
62
86
|
}
|
|
87
|
+
|
|
63
88
|
return out;
|
|
64
89
|
};
|
|
90
|
+
|
|
65
91
|
return json(fit(snapshot, budget));
|
|
66
92
|
}
|
|
67
93
|
|
|
@@ -70,6 +96,7 @@ function spill(fullText, config) {
|
|
|
70
96
|
fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
|
|
71
97
|
const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
|
|
72
98
|
fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
99
|
+
|
|
73
100
|
return file;
|
|
74
101
|
}
|
|
75
102
|
|
|
@@ -82,7 +109,9 @@ export function packageHostResult(raw, config) {
|
|
|
82
109
|
let truncated = capped.truncated || details?.outputTruncated === true;
|
|
83
110
|
const image = raw?.content?.find(part => part?.type === "image");
|
|
84
111
|
const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
|
|
112
|
+
|
|
85
113
|
if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
|
|
114
|
+
|
|
86
115
|
if (batch) {
|
|
87
116
|
result.itemErrors = (details.itemErrors ?? []).map(error => error == null ? null : truncateChars(String(error), Math.max(1, Math.floor(maxChars / batch.length)), "error").text);
|
|
88
117
|
let remaining = maxChars;
|
|
@@ -91,16 +120,22 @@ export function packageHostResult(raw, config) {
|
|
|
91
120
|
const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
|
|
92
121
|
remaining -= bounded.text.length;
|
|
93
122
|
truncated ||= bounded.truncated;
|
|
123
|
+
|
|
94
124
|
return bounded.text;
|
|
95
125
|
});
|
|
96
126
|
}
|
|
127
|
+
|
|
97
128
|
result.truncated = truncated;
|
|
129
|
+
|
|
98
130
|
if (truncated) {
|
|
99
131
|
result.originalChars = batch ? batch.reduce((sum, item) => sum + String(item).length, 0) : text.length;
|
|
132
|
+
|
|
100
133
|
if (config.spillDir) {
|
|
101
134
|
const pointer = spill(batch ? batch.join("\n---\n") : text, config);
|
|
135
|
+
|
|
102
136
|
if (pointer) {
|
|
103
137
|
result.spill = pointer;
|
|
138
|
+
|
|
104
139
|
if (!batch) {
|
|
105
140
|
const footer = "\n[full output spilled to " + pointer + "]";
|
|
106
141
|
result.value = footer.length <= maxChars
|
|
@@ -110,32 +145,43 @@ export function packageHostResult(raw, config) {
|
|
|
110
145
|
}
|
|
111
146
|
}
|
|
112
147
|
}
|
|
148
|
+
|
|
113
149
|
return result;
|
|
114
150
|
}
|
|
115
151
|
|
|
116
152
|
export function packageFinalReturn(value, logs, config) {
|
|
117
153
|
const images = [];
|
|
118
154
|
let imageBytes = 0;
|
|
155
|
+
|
|
119
156
|
const collect = input => {
|
|
120
157
|
if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
|
|
121
158
|
imageBytes += Buffer.byteLength(input.data, "base64");
|
|
159
|
+
|
|
122
160
|
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
161
|
images.push({ type: "image", data: input.data, mimeType: input.mimeType });
|
|
162
|
+
|
|
124
163
|
return `[image ${images.length}: ${input.mimeType}]`;
|
|
125
164
|
}
|
|
165
|
+
|
|
126
166
|
if (Array.isArray(input)) return input.map(collect);
|
|
167
|
+
|
|
127
168
|
if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collect(child)]));
|
|
169
|
+
|
|
128
170
|
return input;
|
|
129
171
|
};
|
|
172
|
+
|
|
130
173
|
value = collect(value);
|
|
131
174
|
const serialized = truncateChars(formatReturn(value), config.maxReturnChars ?? 32000, "return");
|
|
132
175
|
const maxLines = config.maxLogLines ?? 100;
|
|
133
176
|
let logTruncated = logs.length > maxLines;
|
|
177
|
+
|
|
134
178
|
const clipped = logs.slice(0, maxLines).map(line => {
|
|
135
179
|
const result = truncateChars(line, config.maxLogLineChars ?? 4096, "log");
|
|
136
180
|
logTruncated ||= result.truncated;
|
|
181
|
+
|
|
137
182
|
return result.text;
|
|
138
183
|
});
|
|
184
|
+
|
|
139
185
|
return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
|
|
140
186
|
returnTruncated: serialized.truncated, logs: clipped, logTruncated, images };
|
|
141
187
|
}
|