pi-supernova 0.3.1 → 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 +220 -3
- package/docs/CHANGELOG.md +40 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -37
- package/package.json +3 -1
- package/src/bridge/catalog.js +37 -2
- package/src/bridge/host-bridge.js +340 -13
- 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 +45 -1
- package/src/context/repo-index.js +63 -1
- 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 +101 -6
- package/src/fs/workspace.js +36 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +151 -18
- 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
|
}
|
|
160
|
-
|
|
229
|
+
|
|
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,7 +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
|
+
|
|
253
|
+
if (recoveryErrors.length) this.onNewFile?.(null);
|
|
254
|
+
|
|
176
255
|
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
177
256
|
throw error;
|
|
178
257
|
} finally {
|
|
@@ -180,8 +259,10 @@ export class CausalVfs {
|
|
|
180
259
|
// A successful rename consumed the temporary path. These are known
|
|
181
260
|
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
182
261
|
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
262
|
+
|
|
183
263
|
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
184
264
|
}
|
|
265
|
+
|
|
185
266
|
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
186
267
|
}
|
|
187
268
|
}
|
|
@@ -189,32 +270,46 @@ export class CausalVfs {
|
|
|
189
270
|
async commit() {
|
|
190
271
|
if (!this.overlays.length) return { committed: 0, depth: 0 };
|
|
191
272
|
const top = this.overlays.at(-1);
|
|
273
|
+
|
|
192
274
|
if (this.overlays.length > 1) {
|
|
193
275
|
const parent = this.overlays[this.overlays.length - 2];
|
|
276
|
+
|
|
194
277
|
for (const [key, value] of top) parent.set(key, value);
|
|
195
278
|
} else {
|
|
196
279
|
await this.flush(top);
|
|
197
280
|
}
|
|
281
|
+
|
|
198
282
|
this.overlays.pop();
|
|
283
|
+
|
|
199
284
|
return { committed: top.size, depth: this.overlays.length };
|
|
200
285
|
}
|
|
201
286
|
|
|
202
287
|
rollback() {
|
|
203
288
|
const top = this.overlays.pop();
|
|
289
|
+
this.mutations.rolledBack += top?.size ?? 0;
|
|
290
|
+
|
|
204
291
|
for (const target of top?.keys() ?? []) {
|
|
205
292
|
if (this.getOverlay(target) === undefined) this.expected.delete(target);
|
|
206
293
|
}
|
|
294
|
+
|
|
207
295
|
return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
|
|
208
296
|
}
|
|
209
297
|
|
|
210
298
|
async prepareExternalMutation(name) {
|
|
211
299
|
this.assertWritable();
|
|
300
|
+
|
|
212
301
|
if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
|
|
213
|
-
|
|
302
|
+
|
|
303
|
+
if (!this.overlays.length) { this.mutations.external++;
|
|
304
|
+
|
|
305
|
+
return false; }
|
|
306
|
+
|
|
214
307
|
const pending = this.overlays[0];
|
|
215
308
|
await this.flush(pending);
|
|
216
309
|
this.assertWritable();
|
|
217
310
|
this.overlays[0] = new Map();
|
|
311
|
+
this.mutations.external++;
|
|
312
|
+
|
|
218
313
|
return pending.size > 0;
|
|
219
314
|
}
|
|
220
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,24 +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
|
+
|
|
72
|
+
if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
|
|
63
73
|
const resolvedCwd = getResolvedCwd(cwd);
|
|
64
74
|
const target = path.resolve(resolvedCwd, inputPath.trim());
|
|
65
75
|
assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
|
|
76
|
+
|
|
66
77
|
if (!allowRoot && target === resolvedCwd) {
|
|
67
78
|
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
68
79
|
}
|
|
80
|
+
|
|
69
81
|
let realRoot = realRoots.get(resolvedCwd);
|
|
82
|
+
|
|
70
83
|
if (!realRoot) {
|
|
71
84
|
realRoot = await fs.realpath(resolvedCwd);
|
|
72
85
|
realRoots.set(resolvedCwd, realRoot);
|
|
73
86
|
}
|
|
87
|
+
|
|
74
88
|
let probe = fresh ? undefined : realNearest.get(target);
|
|
89
|
+
|
|
75
90
|
if (!probe) {
|
|
76
91
|
probe = await realpathNearest(target);
|
|
92
|
+
|
|
77
93
|
if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
|
|
78
94
|
realNearest.set(target, probe);
|
|
79
95
|
}
|
|
96
|
+
|
|
80
97
|
assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
|
|
98
|
+
|
|
81
99
|
return target;
|
|
82
100
|
}
|
|
83
101
|
|
|
@@ -86,21 +104,25 @@ export async function runCommand(argv, options = {}) {
|
|
|
86
104
|
const cwd = options.cwd || process.cwd();
|
|
87
105
|
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
88
106
|
const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
|
|
107
|
+
|
|
89
108
|
return new Promise((resolve, reject) => {
|
|
90
109
|
const child = spawn(argv[0], argv.slice(1), {
|
|
91
110
|
cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
|
|
92
111
|
});
|
|
112
|
+
|
|
93
113
|
let stdout = "";
|
|
94
114
|
let stderr = "";
|
|
95
115
|
let settled = false;
|
|
96
116
|
let outputTruncated = false;
|
|
97
117
|
let terminationError;
|
|
98
118
|
let escalation;
|
|
119
|
+
|
|
99
120
|
const cleanup = () => {
|
|
100
121
|
clearTimeout(timer);
|
|
101
122
|
clearTimeout(escalation);
|
|
102
123
|
options.signal?.removeEventListener("abort", onAbort);
|
|
103
124
|
};
|
|
125
|
+
|
|
104
126
|
const fail = error => {
|
|
105
127
|
if (settled) return;
|
|
106
128
|
settled = true;
|
|
@@ -110,12 +132,16 @@ export async function runCommand(argv, options = {}) {
|
|
|
110
132
|
error.stderr = stderr;
|
|
111
133
|
error.outputTruncated = outputTruncated;
|
|
112
134
|
const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
|
|
135
|
+
|
|
113
136
|
if (output) error.message += "\n" + output;
|
|
137
|
+
|
|
114
138
|
if (outputTruncated) error.message += "\n[output truncated]";
|
|
115
139
|
reject(error);
|
|
116
140
|
};
|
|
141
|
+
|
|
117
142
|
const signalTree = signal => {
|
|
118
143
|
if (!child.pid) return;
|
|
144
|
+
|
|
119
145
|
if (process.platform === "win32") {
|
|
120
146
|
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
|
|
121
147
|
killer.on("error", () => child.kill(signal));
|
|
@@ -123,6 +149,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
123
149
|
try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
|
|
124
150
|
}
|
|
125
151
|
};
|
|
152
|
+
|
|
126
153
|
const terminate = error => {
|
|
127
154
|
if (settled || terminationError) return;
|
|
128
155
|
terminationError = error;
|
|
@@ -130,13 +157,18 @@ export async function runCommand(argv, options = {}) {
|
|
|
130
157
|
// Keep ownership after the direct child exits: descendants may ignore SIGTERM.
|
|
131
158
|
escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
|
|
132
159
|
};
|
|
160
|
+
|
|
133
161
|
const onAbort = () => terminate(new Error("aborted"));
|
|
134
162
|
const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
|
|
163
|
+
|
|
135
164
|
const append = (current, chunk) => {
|
|
136
165
|
const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
|
|
166
|
+
|
|
137
167
|
if (chunk.length > remaining) outputTruncated = true;
|
|
168
|
+
|
|
138
169
|
return remaining ? current + chunk.slice(0, remaining) : current;
|
|
139
170
|
};
|
|
171
|
+
|
|
140
172
|
child.stdout.setEncoding("utf8");
|
|
141
173
|
child.stderr.setEncoding("utf8");
|
|
142
174
|
child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
|
|
@@ -144,6 +176,7 @@ export async function runCommand(argv, options = {}) {
|
|
|
144
176
|
child.on("error", fail);
|
|
145
177
|
child.on("close", (code, signal) => {
|
|
146
178
|
if (settled) return;
|
|
179
|
+
|
|
147
180
|
if (terminationError) {
|
|
148
181
|
// A closed pipe alone says nothing about descendants. Only ESRCH proves
|
|
149
182
|
// the owned POSIX group is gone; otherwise retain the escalation timer.
|
|
@@ -151,13 +184,16 @@ export async function runCommand(argv, options = {}) {
|
|
|
151
184
|
try { process.kill(-child.pid, 0); }
|
|
152
185
|
catch (error) { if (error.code === "ESRCH") fail(terminationError); }
|
|
153
186
|
}
|
|
187
|
+
|
|
154
188
|
return;
|
|
155
189
|
}
|
|
190
|
+
|
|
156
191
|
settled = true;
|
|
157
192
|
cleanup();
|
|
158
193
|
resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
|
|
159
194
|
});
|
|
160
195
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
196
|
+
|
|
161
197
|
if (options.signal?.aborted) onAbort();
|
|
162
198
|
});
|
|
163
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
|
}
|