pi-supernova 0.5.0 → 0.7.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.
Files changed (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -1,959 +1,19 @@
1
-
2
1
  import * as fs from "node:fs/promises";
3
2
  import * as path from "node:path";
4
- import { homedir } from "node:os";
5
3
  import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
6
- import { isString, isNumber, isFunction, isObject, looksLikePath } from "../shared/decode.js";
4
+ import { truncateChars } from "../output/format.js";
5
+ import { isString, isFunction, isObject } from "../shared/decode.js";
7
6
  import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
8
7
  import { unknownToolMessage } from "./catalog.js";
9
- import { extractStructuralSurface } from "../context/surface.js";
10
- import { pickSpan } from "../context/spans.js";
11
- import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
12
- import { executeSnap } from "../context/snap.js";
13
- import { selectEvidence } from "../context/evidence.js";
8
+ import { toolIsCallable, resolveInvokeTarget } from "./invoke.js";
9
+ import { buildWriteDiff } from "../fs/diff.js";
14
10
  import { WorkspaceIndex } from "../context/repo-index.js";
15
- import { outlineFile } from "../context/outline.js";
16
11
  import { SeenLedger } from "../context/ledger.js";
17
- import { quickCheck } from "../fs/check.js";
18
- import { declaredName } from "../context/repo-index.js";
19
12
  import { CausalVfs } from "../fs/vfs.js";
20
- import { MAX_JSON_BYTES, jsonProjector, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
21
- import { applyPatchToText } from "../fs/patch.js";
22
- import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash, assertFilesystemPath } from "../fs/workspace.js";
23
- import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
24
-
25
- function textResult(text, details) {
26
- return {
27
- content: [{ type: "text", text: String(text ?? "") }],
28
- details: details || {},
29
- };
30
- }
31
-
32
- function resultDiff(response) {
33
- let details = response?.details;
34
-
35
- if (isString(details)) {
36
- try {
37
- details = JSON.parse(details);
38
- } catch {
39
- return undefined;
40
- }
41
- }
42
-
43
- return isObject(details) ? details.diff : undefined;
44
- }
45
-
46
- /** Unwrap a single matching quote pair around the whole string (`'git status'`). */
47
- function unwrapIfFullyQuoted(s) {
48
- if (s.length < 2) return s;
49
- const q = s[0];
50
-
51
- if (q !== "'" && q !== '"') return s;
52
-
53
- if (s[s.length - 1] !== q) return s;
54
- const inner = s.slice(1, -1);
55
-
56
- if (inner.includes(q)) return s;
57
-
58
- return inner;
59
- }
60
-
61
- function sliceLines(text, offset, limit) {
62
- if (!isNumber(offset) && !isNumber(limit)) return text;
63
- const lines = text.split("\n");
64
- const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
65
- const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
66
-
67
- return lines.slice(startIndex, startIndex + count).join("\n");
68
- }
69
-
70
- function resolveReadPath(cwd, target) {
71
- if (!isString(target) || !target.trim()) throw new Error("read requires path");
72
- const input = assertFilesystemPath(target, "read");
73
-
74
- return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
75
- }
76
-
77
- async function probeExistingPath(cwd, targetParam, vfs) {
78
- const targetPath = resolveReadPath(cwd, targetParam);
79
-
80
- if (vfs.getOverlay(targetPath) !== undefined) return { path: targetPath, directory: false };
81
-
82
- try {
83
- const st = await fs.stat(targetPath);
84
-
85
- return { path: targetPath, directory: st.isDirectory() };
86
- } catch (err) {
87
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
88
-
89
- if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
90
-
91
- return null;
92
- }
93
- }
94
-
95
- const EDIT_PREVIEW_LINES = 16;
96
-
97
- function sourceLines(content) {
98
- const raw = content.split("\n");
99
-
100
- if (raw.at(-1) === "") raw.pop();
101
-
102
- return raw;
103
- }
104
-
105
- function lineNumberAt(content, index) {
106
- let line = 1;
107
-
108
- for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) line++;
109
-
110
- return line;
111
- }
112
-
113
- function formatNumberedLine(n, text) {
114
- return String(n).padStart(5) + " " + text;
115
- }
116
-
117
- function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
118
- const lines = sourceLines(content);
119
-
120
- if (lines.length === 0) return "0 lines";
121
- const shown = lines.slice(0, cap);
122
- const body = shown.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
123
- const suffix = lines.length > cap ? lines.length + " lines total" : lines.length + " lines";
124
-
125
- return body + "\n" + suffix;
126
- }
127
-
128
- function applyReplacements(target, content, requestedEdits) {
129
- if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
130
-
131
- const matches = requestedEdits.map((replacement) => {
132
- if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
133
- throw new Error("edit requires non-empty oldText");
134
- }
135
-
136
- if (!isString(replacement?.newText)) throw new Error("edit requires newText");
137
- const index = content.indexOf(replacement.oldText);
138
-
139
- if (index < 0) {
140
- throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
141
- }
142
- const second = content.indexOf(replacement.oldText, index + 1);
143
-
144
- if (second >= 0) {
145
- const lines = sourceLines(content);
146
- const a = lineNumberAt(content, index);
147
- const b = lineNumberAt(content, second);
148
-
149
- throw new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lines[a - 1] ?? "") + "\n" + formatNumberedLine(b, lines[b - 1] ?? ""));
150
- }
151
-
152
- return { ...replacement, index, end: index + replacement.oldText.length };
153
- });
154
-
155
- matches.sort((a, b) => a.index - b.index);
156
-
157
- for (let i = 1; i < matches.length; i++) {
158
- if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
159
- }
160
-
161
- let updated = content;
162
-
163
- for (let i = matches.length - 1; i >= 0; i--) {
164
- const match = matches[i];
165
- updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
166
- }
167
-
168
- return { updated, matches };
169
- }
170
-
171
- function applyViewReplace(target, content, start, end, oldText, newText) {
172
- const current = sliceLines(content, start, end - start + 1);
173
-
174
- if (current !== oldText) {
175
- const shown = current.length ? current : content;
176
-
177
- throw new Error("edit view is stale in " + target + ": lines " + start + "-" + end + " changed\n" + numberedPreview(shown));
178
- }
179
-
180
- const hadTrail = content.endsWith("\n");
181
- const lines = content.split("\n");
182
-
183
- if (hadTrail && lines.at(-1) === "") lines.pop();
184
- const insert = newText.split("\n");
185
-
186
- if (newText.endsWith("\n") && insert.at(-1) === "") insert.pop();
187
- const updated = [...lines.slice(0, start - 1), ...insert, ...lines.slice(end)].join("\n") + (hadTrail ? "\n" : "");
188
-
189
- return { updated, oldText, newText };
190
- }
191
-
192
- function formatDirectoryEntry(name, type, size = 0) {
193
- const sizeSuffix = size ? `, ${size} bytes` : "";
194
-
195
- return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
196
- }
197
-
198
- async function formatLsEntry(dirPath, entry) {
199
- const isDir = entry.isDirectory();
200
- const isSym = entry.isSymbolicLink();
201
- const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
202
- let size = 0;
203
-
204
- try {
205
- if (!isDir && !isSym) {
206
- const st = await fs.stat(path.join(dirPath, entry.name));
207
- size = st.size;
208
- }
209
- } catch {}
210
-
211
- return formatDirectoryEntry(entry.name, typeLabel, size);
212
- }
213
-
214
- function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
215
- const reads = createNativeScheduler();
216
-
217
- async function sourceRead(query, searchDir, signal, params = {}) {
218
- params = { ...params, resolve: params.resolve !== false };
219
- const cwd = getCwd();
220
-
221
- const includeHidden = path.relative(cwd, searchDir).split(path.sep)
222
- .some(segment => segment.startsWith(".") && segment.length > 1);
223
-
224
- const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
225
- pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
226
- overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
227
-
228
- return openSource(result, params, signal, undefined, query);
229
- }
230
-
231
- async function openSource(result, params, signal, resolvedPath, query) {
232
- const cwd = getCwd();
233
-
234
- if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
235
- signal?.throwIfAborted();
236
- const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path, query);
237
- const block = opened.content[0];
238
-
239
- if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
240
- const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
241
-
242
- const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
243
- text: block.text.slice(0, sourceChars), complete, nextOffset };
244
-
245
- return textResult(params.resolve ? JSON.stringify(source) : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
246
- { ...opened.details, isSnap: true });
247
- }
248
-
249
- async function readDirectory(dirPath, signal) {
250
- signal?.throwIfAborted();
251
- const rows = new Map();
252
-
253
- for (const file of vfs.getOverlayPaths()) {
254
- const relative = path.relative(dirPath, file);
255
-
256
- if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
257
- const [name, child] = relative.split(path.sep);
258
- rows.set(name, child === undefined
259
- ? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
260
- : formatDirectoryEntry(name, "dir"));
261
- }
262
-
263
- let entries;
264
-
265
- try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
266
- if (error.code !== "ENOENT" || rows.size === 0) throw error;
267
- entries = [];
268
- }
269
-
270
- for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
271
-
272
- return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
273
- }
274
-
275
- async function readAdapter(params, signal) {
276
- signal?.throwIfAborted();
277
- const cwd = getCwd();
278
- const targetParam = params?.path ?? params?.target;
279
-
280
- if (Array.isArray(targetParam)) {
281
- if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
282
-
283
- for (const p of targetParam) if (!isString(p) || !p.trim()) throw new Error("read paths must be non-empty strings");
284
-
285
- const results = await Promise.all(targetParam.map(async p => {
286
- try {
287
- const block = (await readAdapter({ ...params, path: p }, signal)).content[0];
288
-
289
- return { text: block.type === "image" ? block : block.text };
290
- } catch (error) {
291
- signal?.throwIfAborted();
292
-
293
- return { text: `[read error: ${p}] ${error.message}`, error: { path: p, message: error.message } };
294
- }
295
- }));
296
-
297
- signal?.throwIfAborted();
298
- const response = textResult("", { count: results.length, batch: true, independent: params._independent === true, items: results.map(r => r.text), itemErrors: results.map(r => r.error?.message ?? null), errors: results.filter(r => r.error).map(r => r.error) });
299
- response.isError = params._independent !== true && results.some(r => r.error);
300
-
301
- return response;
302
- }
303
-
304
- return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
305
- }
306
-
307
- async function resolveSessionResource(uri, signal) {
308
- const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
309
-
310
- if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
311
- const kind = match[1].toLowerCase();
312
- const id = decodeURIComponent(match[2]);
313
-
314
- if (!id || id === "." || id === ".." || (/[/\\]/u.test(id) || Array.from(id).some(char => char.charCodeAt(0) < 32)) || (kind === "artifact" && !/^\d+$/.test(id))) throw new Error("invalid session resource ID");
315
- const dir = hooks.artifactsDir?.();
316
-
317
- if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
318
- signal?.throwIfAborted();
319
- const root = await fs.realpath(dir);
320
- let file = id + ".md";
321
-
322
- if (kind === "artifact") {
323
- const matches = [];
324
- let count = 0;
325
-
326
- for await (const entry of await fs.opendir(root)) {
327
- signal?.throwIfAborted();
328
-
329
- if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
330
-
331
- if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
332
- }
333
-
334
- if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
335
- file = matches[0];
336
- }
337
-
338
- const target = await fs.realpath(path.join(root, file));
339
-
340
- if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
341
-
342
- if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
343
- signal?.throwIfAborted();
344
-
345
- return target;
346
- }
347
-
348
- async function readSingle(params, cwd, targetParam, signal) {
349
- params = sessionJsonArgs({ ...params, path: targetParam });
350
- validateJsonRead(params);
351
- targetParam = params.path;
352
-
353
- if (isString(targetParam) && /^(?:agent|artifact):\/\//i.test(targetParam)) {
354
- const target = await resolveSessionResource(targetParam, signal);
355
-
356
- return params.resolve
357
- ? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
358
- : readFile(target, params, undefined, targetParam);
359
- }
360
-
361
- if (isString(params?.query)) {
362
- const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
363
-
364
- return sourceRead(params.query, scope, signal, params);
365
- }
366
-
367
- const existing = await probeExistingPath(cwd, targetParam, vfs);
368
-
369
- if (existing) {
370
- if (!existing.directory) return params.resolve
371
- ? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
372
- : readFile(existing.path, params);
373
-
374
- if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
375
-
376
- return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
377
- }
378
-
379
- if (params.json === undefined && !looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
380
- const targetPath = resolveReadPath(cwd, targetParam);
381
-
382
- return readFile(targetPath, params);
383
- }
384
-
385
- /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
386
- async function readFile(targetPath, params, sourceLine, displayPath, query) {
387
- const cwd = getCwd();
388
- const rel = displayPath ?? relativeSlash(cwd, targetPath);
389
-
390
- if (params.json !== undefined) {
391
- const project = jsonProjector(params.json);
392
- const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES });
393
- let document;
394
-
395
- try { document = JSON.parse(text); }
396
- catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
397
-
398
- const many = Array.isArray(params.json);
399
- let remaining = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256) - (many ? params.json.length + 1 : 0);
400
- const parts = [];
401
-
402
- for (const value of project(document)) {
403
- const encoded = JSON.stringify(value);
404
- remaining -= encoded.length;
405
-
406
- if (remaining < 0) throw new Error("JSON selection exceeds the read budget; select narrower fields or an array slice such as .items[0:10]");
407
- parts.push(encoded);
408
- }
409
-
410
- return textResult(many ? "[" + parts.join(",") + "]" : parts[0], { path: targetPath, json: true, complete: true });
411
- }
412
-
413
- const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
414
-
415
- if (mime) {
416
- if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
417
- const staged = vfs.getOverlay(targetPath);
418
- const bytes = staged === undefined ? await fs.readFile(targetPath) : Buffer.from(staged);
419
-
420
- return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
421
- }
422
-
423
- const text = await vfs.read(targetPath);
424
- index.touch(rel);
425
-
426
- if (isString(params?.about)) {
427
- const entry = WorkspaceIndex.fromText(targetPath, text);
428
- const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
429
-
430
- if (outline) {
431
- recordOutlineOrigins(rel, outline.text);
432
-
433
- return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
434
- }
435
- }
436
-
437
- const explicit = isNumber(params?.offset) || isNumber(params?.limit);
438
- const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
439
- let offset = params?.offset;
440
- let limit = params?.limit;
441
-
442
- if (!explicit && params.resolve) {
443
- const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(targetPath, text));
444
- const span = pickSpan(spans, { line: sourceLine, name: query });
445
-
446
- if (span) {
447
- offset = span.start;
448
- limit = span.end - span.start + 1;
449
- }
450
- }
451
-
452
- offset ??= sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1;
453
- const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
454
- const sliced = sliceLines(text, offset, limit);
455
-
456
- if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
457
- throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use json:".field" for JSON reports, about for text selection, edit() for replacements, or reconstruct resolve:true source windows`);
458
- }
459
-
460
- if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
461
- if (!explicit && !params.resolve && path.extname(targetPath).toLowerCase() === ".json") throw new Error("incomplete JSON read of " + rel + "; use the json selector option to parse the whole document before projection, or explicit offset/limit for raw text windows");
462
- let cap = budget - 160;
463
-
464
- if (params.resolve) {
465
- // Budget the actual JSON string, not a pessimistic fixed escape multiplier.
466
- let low = 0, high = Math.max(0, cap);
467
-
468
- while (low < high) {
469
- const mid = Math.ceil((low + high) / 2);
470
-
471
- if (JSON.stringify(sliced.slice(0, mid)).length <= budget - 160) low = mid;
472
- else high = mid - 1;
473
- }
474
-
475
- cap = low;
476
- }
477
-
478
- const end = sliced.lastIndexOf("\n", cap);
479
-
480
- if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
481
- const body = sliced.slice(0, end + 1);
482
- const next = firstLine + body.split("\n").length - 1;
483
-
484
- return textResult(body + `\n[read truncated; continue with read({path:${JSON.stringify(rel)}, offset:${next}})]`, { path: targetPath, outputTruncated: true, nextOffset: next, firstLine, lastLine: next - 1, sourceChars: body.length, complete: false });
485
- }
486
-
487
- ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
488
-
489
- return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + sliced.split("\n").length - 1 - Number(sliced.endsWith("\n")), sourceChars: sliced.length, complete: sliced === text });
490
- }
491
-
492
- /**
493
- * The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
494
- * lines with numbers, a quick structural check, and bounded lexical reference hints.
495
- * These do not replace tests or semantic caller resolution.
496
- */
497
- async function editSummary(cwd, target, original, updated, diff, signal, span) {
498
- const rel = relativeSlash(cwd, target);
499
- const newLines = updated.split("\n");
500
- const ranges = [];
501
-
502
- if (span && Number.isInteger(span.start) && Number.isInteger(span.end) && span.start >= 1 && span.end >= span.start) {
503
- ranges.push({ start: span.start, end: Math.min(newLines.length, span.end) });
504
- } else {
505
- const positions = diff.lines.filter(row => row.type !== "context")
506
- .map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
507
-
508
- for (const line of positions) {
509
- const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
510
-
511
- if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
512
- else ranges.push({ start, end });
513
- }
514
- }
515
-
516
- const blocks = [];
517
- const perRange = Math.max(1, Math.floor(40 / Math.max(1, ranges.length)));
518
-
519
- for (const { start, end } of ranges) {
520
- const last = Math.min(end, start + perRange - 1);
521
- const lines = newLines.slice(start - 1, last);
522
- ledger.recordOrigin(rel, start, lines);
523
- blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
524
-
525
- if (last < end) blocks.push("[continue with read({path:" + JSON.stringify(rel) + ",offset:" + (last + 1) + ",limit:" + (end - last) + "})]");
526
- }
527
-
528
- let out = blocks.join("\n");
529
- const check = quickCheck(updated, path.extname(target));
13
+ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
14
+ import { createNativeAdapters } from "../adapters/index.js";
15
+ import { resultDiff, boundedWriteDiff, writeSnapshot } from "../fs/text-ops.js";
530
16
 
531
- if (check && !check.ok) out += `\ncheck: ${check.message}`;
532
- const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
533
-
534
- if (refs) out += `\n${refs}`;
535
-
536
- return out;
537
- }
538
-
539
- async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
540
- // Diff rows carry the replaced fragments; declarations live on whole file lines.
541
- const oldLines = original.split("\n");
542
- const newLines = updated.split("\n");
543
- const names = new Set();
544
- const spans = new Map();
545
-
546
- for (const l of diff.lines) {
547
- if (l.type === "context") continue;
548
- const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
549
- const name = declaredName((l.type === "remove" ? oldLines : newLines)[number - 1] ?? "");
550
-
551
- if (name) names.add(name);
552
- else {
553
- if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
554
- const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
555
-
556
- if (owner?.name) names.add(owner.name);
557
- }
558
-
559
- if (names.size >= 3) break;
560
- }
561
-
562
- if (names.size === 0) return "";
563
-
564
- try {
565
- const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
566
- excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
567
-
568
- const parts = [];
569
-
570
- for (const [name, refs] of references) {
571
- if (refs.length) parts.push(name + " also referenced in " + refs.slice(0, 6).join(", ") + (refs.length > 6 ? " (more matches)" : ""));
572
- }
573
-
574
- if (incomplete) parts.push("references incomplete: search budget reached");
575
-
576
- return parts.join("\n");
577
- } catch (error) {
578
- signal?.throwIfAborted();
579
-
580
- return "references unavailable: " + error.message;
581
- }
582
- }
583
-
584
- const SOURCE_REF = /((?:\/|[A-Za-z]:[\\/])?(?:[\w.@-]+[\\/])*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
585
-
586
- /** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
587
- async function sourceWindow(cwd, commandCwd, file, lineNo) {
588
- const candidate = path.resolve(commandCwd, file);
589
- let text, rel;
590
-
591
- try {
592
- const root = await fs.realpath(cwd);
593
-
594
- if (!candidate.startsWith(path.resolve(cwd) + path.sep) && !candidate.startsWith(root + path.sep)) return null;
595
- const real = await fs.realpath(candidate);
596
-
597
- if (!real.startsWith(root + path.sep) || (await fs.stat(real)).size > 1024 * 1024) return null;
598
- text = await fs.readFile(real, "utf8");
599
- rel = relativeSlash(root, real);
600
- } catch { return null; }
601
-
602
- const raw = text.split("\n");
603
-
604
- if (lineNo < 1 || lineNo > raw.length) return null;
605
- const start = Math.max(1, lineNo - 2);
606
- const rows = [];
607
-
608
- for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
609
- ledger.recordOrigin(rel, start, rows);
610
-
611
- return rel + ":" + lineNo + "\n" + rows.join("\n");
612
- }
613
-
614
- /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
615
- async function sourceForReferences(cwd, commandCwd, output) {
616
- const seen = new Set();
617
- const blocks = [];
618
-
619
- for (const m of output.matchAll(SOURCE_REF)) {
620
- const key = m[1] + ":" + m[2];
621
-
622
- if (seen.has(key)) continue;
623
-
624
- if (seen.size >= 4) break;
625
- seen.add(key);
626
- const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
627
-
628
- if (block) blocks.push(block);
629
- }
630
-
631
- return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
632
- }
633
-
634
- function outlineOptions(params, references) {
635
- const options = { references };
636
-
637
- if (params?.maxChars) options.maxChars = params.maxChars;
638
-
639
- return options;
640
- }
641
-
642
- /** Outline lines carry their own line numbers (" 330 text"); provenance follows them. */
643
- function recordOutlineOrigins(rel, outlineText) {
644
- for (const line of outlineText.split("\n")) {
645
- const m = /^\s*(\d+) (.*)$/.exec(line);
646
-
647
- if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
648
- }
649
- }
650
-
651
- /** Where else a name appears (declaration line excluded), for outlines and edit results. */
652
- async function referenceFinder(cwd, targetPath) {
653
- const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
654
-
655
- if (!index.canScan(files)) return () => [];
656
-
657
- return (name, excludeLine) => {
658
- if (!name || name.length < 3) return [];
659
- const escaped = name.replace(/[$]/g, (c) => "\\" + c);
660
- const regex = new RegExp("\\b" + escaped + "\\b");
661
-
662
- return index
663
- .grepRows(files, regex, cwd, file => vfs.getOverlay(file))
664
- .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
665
- .map((r) => r.rel + ":" + r.line);
666
- };
667
- }
668
-
669
- hooks.summarizeEdit = editSummary;
670
-
671
- return {
672
- read: readAdapter,
673
- async write(params, signal) {
674
- const cwd = getCwd();
675
- const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
676
-
677
- if (signal?.aborted) throw new Error("aborted");
678
-
679
- if (!isString(params?.content)) throw new Error("write requires string content");
680
-
681
- if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
682
- let content = params.content;
683
-
684
- if (params.allowReadArtifacts !== true && /\[read truncated;|…\[(?:host-result|output|value) truncated \d+ chars\]…/u.test(content)) {
685
- throw new Error("refusing to write truncated read output; use edit() or reconstruct complete source windows. Set allowReadArtifacts:true only to intentionally write literal truncation-marker text");
686
- }
687
-
688
- let prevText = "";
689
-
690
- try {
691
- prevText = await vfs.read(target, { preserveRead: true });
692
- } catch (error) { if (error.code !== "ENOENT") throw error; }
693
-
694
- if (params.append === true) content = prevText + content;
695
- const { speculative } = await vfs.write(target, content);
696
- index.touch(relativeSlash(cwd, target));
697
- const diff = buildWriteDiff(target, prevText, content);
698
- const tag = speculative ? " (speculative)" : "";
699
- const check = quickCheck(content, path.extname(target));
700
- const warning = check && !check.ok ? "\ncheck: " + check.message : "";
701
-
702
- return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
703
- },
704
- async edit(params, signal) {
705
- const cwd = getCwd();
706
- const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
707
-
708
- if (signal?.aborted) throw new Error("aborted");
709
-
710
- const content = await vfs.read(target);
711
-
712
- if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
713
- const windowNext = isString(params.oldText)
714
- ? applyReplacements(target, params.viewText, [{ oldText: params.oldText, newText: params.newText }]).updated
715
- : params.newText;
716
- const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, params.viewText, windowNext);
717
- const { speculative } = await vfs.write(target, updated);
718
- index.touch(relativeSlash(cwd, target));
719
- const diffFrom = isString(params.oldText) ? params.oldText : params.viewText;
720
- const diffTo = isString(params.oldText) ? params.newText : windowNext;
721
- const diff = buildEditDiff(target, content, diffFrom, diffTo);
722
- const inserted = sourceLines(windowNext);
723
- const spanEnd = params.viewStart + Math.max(inserted.length, 1) - 1;
724
- const summary = await editSummary(cwd, target, content, updated, diff, signal, { start: params.viewStart, end: spanEnd });
725
-
726
- return textResult(summary, { path: target, speculative, diff });
727
- }
728
-
729
- const requestedEdits = Array.isArray(params?.edits)
730
- ? params.edits
731
- : [{ oldText: params?.oldText, newText: params?.newText }];
732
- const { updated, matches } = applyReplacements(target, content, requestedEdits);
733
- const { speculative } = await vfs.write(target, updated);
734
- index.touch(relativeSlash(cwd, target));
735
-
736
- const diff =
737
- matches.length === 1
738
- ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
739
- : buildMultiEditDiff(target, content, matches);
740
-
741
- const summary = await editSummary(cwd, target, content, updated, diff, signal);
742
-
743
- return textResult(summary, { path: target, speculative, diff });
744
- },
745
- async apply_patch(params, signal) {
746
- const cwd = getCwd();
747
- let inputPath = params?.path;
748
-
749
- if (!inputPath && isString(params?.patch)) {
750
- const headerMatch = /^\+\+\+\s+[ab]\/(.+)$/m.exec(params.patch) || /^---\s+[ab]\/(.+)$/m.exec(params.patch);
751
-
752
- if (headerMatch) inputPath = headerMatch[1].trim();
753
- }
754
-
755
- const target = await resolveWorkspacePath(cwd, inputPath, "apply_patch", false);
756
-
757
- if (!isString(params?.patch) || !params.patch.trim()) {
758
- throw new Error("apply_patch requires patch");
759
- }
760
-
761
- if (signal?.aborted) throw new Error("aborted");
762
-
763
- const original = await vfs.read(target);
764
- const { resultText, hunkCount } = applyPatchToText(original, params.patch);
765
- const { speculative } = await vfs.write(target, resultText);
766
- const diff = buildPatchDiff(target, params.patch);
767
- index.touch(relativeSlash(cwd, target));
768
- const summary = await editSummary(cwd, target, original, resultText, diff, signal);
769
-
770
- return textResult(summary, {
771
- path: target,
772
- hunks: hunkCount,
773
- speculative,
774
- diff,
775
- });
776
- },
777
- async snap(params, signal) {
778
- const cwd = getCwd();
779
-
780
- if (!isString(params?.query) || !params.query.trim()) {
781
- throw new Error("snap requires query");
782
- }
783
-
784
- if (signal?.aborted) throw new Error("aborted");
785
- const snapTarget = params?.path ? await resolveWorkspacePath(cwd, params.path, "snap", true) : cwd;
786
- const relativeRoot = path.relative(cwd, snapTarget);
787
-
788
- const includeHidden = Boolean(params?.path) && relativeRoot
789
- .split(path.sep)
790
- .some((segment) => segment.startsWith(".") && segment.length > 1);
791
-
792
- const res = await executeSnap({
793
- query: params.query,
794
- searchDir: snapTarget,
795
- root: cwd,
796
- includeHidden,
797
- overlayText: (p) => vfs.getOverlay(p),
798
- pendingPaths: vfs.getOverlayPaths(),
799
- signal,
800
- });
801
-
802
- return textResult(JSON.stringify(res, null, 2), res);
803
- },
804
- async evidence(params, signal) {
805
- const cwd = getCwd();
806
-
807
- if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
808
-
809
- if (signal?.aborted) throw new Error("aborted");
810
- const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
811
- const options = {};
812
-
813
- if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
814
-
815
- if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
816
- const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
817
-
818
- for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
819
-
820
- return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
821
- },
822
- async surface(params, signal) {
823
- const cwd = getCwd();
824
- const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
825
-
826
- if (signal?.aborted) throw new Error("aborted");
827
- const text = await vfs.read(target);
828
- const ext = path.extname(target);
829
- const outline = extractStructuralSurface(text, ext);
830
-
831
- return textResult(JSON.stringify(outline, null, 2), { path: target, count: outline.items.length });
832
- },
833
- async bash(params, signal) {
834
- const cwd = getCwd();
835
- const literal = Array.isArray(params?.args) && process.platform !== "win32" && params.args.length === Object.keys(params.args).length && params.args.every(isString);
836
-
837
- if (literal && (!isString(params.command) || !Array.isArray(params.args) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
838
- const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
839
-
840
- if (!command.trim()) throw new Error("bash requires command");
841
- const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
842
-
843
- // String commands keep shell semantics; literal argv needs no quoting or shell startup.
844
- const argv = literal ? [command, ...params.args] : ["bash", "-c", command];
845
-
846
- const transactionBarrier = await vfs.prepareExternalMutation("bash");
847
- let res;
848
-
849
- try {
850
- res = await runCommand(argv, {
851
- cwd: targetCwd,
852
- env: hooks.commandEnv(),
853
- commandLabel: literal ? command : undefined,
854
- timeoutMs: params?.timeoutMs,
855
- signal,
856
- maxOutputChars: config.maxCallResultChars,
857
- });
858
- } catch (error) {
859
- if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message);
860
- throw error;
861
- } finally {
862
- vfs.invalidateCache();
863
- index.invalidate();
864
- clearPathCache();
865
- hooks.workspaceChanged();
866
- }
867
-
868
- const { stdout, stderr } = res;
869
- let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
870
-
871
- if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
872
-
873
- return {
874
- content: [{ type: "text", text }],
875
- details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
876
- isError: res.exitCode !== 0,
877
- };
878
- },
879
- async grep(params, signal) {
880
- const cwd = getCwd();
881
- const pattern = String(params?.pattern || "");
882
-
883
- if (!pattern) throw new Error("grep requires pattern");
884
- const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
885
- const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
886
-
887
- if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
888
- // Large tree: real rg keeps its own output format.
889
- const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
890
-
891
- if (res.exitCode !== 0 && res.exitCode !== 1) {
892
- throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
893
- }
894
-
895
- return textResult(res.stdout, { exitCode: res.exitCode });
896
- },
897
- async glob(params, signal) {
898
- const cwd = getCwd();
899
- const pattern = String(params?.pattern || "");
900
-
901
- if (!pattern) throw new Error("glob requires pattern");
902
- const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
903
-
904
- if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
905
- const indexed = await listIndexed(index, cwd, cwd, pattern);
906
-
907
- if (indexed !== null) return textResult(indexed, { via: "index" });
908
-
909
- const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
910
- () => null,
911
- );
912
-
913
- if (rg && (rg.exitCode === 0 || rg.exitCode === 1)) {
914
- return textResult(rg.stdout, { via: "rg" });
915
- }
916
-
917
- const findPattern = pattern.startsWith("./") ? pattern : `./${pattern}`;
918
-
919
- const fallback = await runCommand(["find", ".", "-type", "f", "-path", findPattern], {
920
- cwd,
921
- timeoutMs: 30_000,
922
- signal,
923
- });
924
-
925
- return textResult(fallback.stdout, { via: "find" });
926
- },
927
- async find(params, signal) {
928
- const cwd = getCwd();
929
- const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
930
- const pattern = params?.pattern || params?.glob;
931
-
932
- if (signal?.aborted) throw new Error("aborted");
933
- const globPattern = pattern ? String(pattern) : null;
934
- const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
935
-
936
- if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
937
- const indexed = await listIndexed(index, searchDir, cwd, globPattern);
938
-
939
- if (indexed !== null) return textResult(indexed, { via: "index" });
940
-
941
- return listWithTools(searchDir, globPattern, cwd, signal);
942
- },
943
- async ls(params, signal) {
944
- const cwd = getCwd();
945
- const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
946
-
947
- return readDirectory(dirPath, signal);
948
- },
949
- };
950
- }
951
-
952
- /**
953
- * Fused INVOKE kernel. Guest RPC is the only caller; fuel is cwd + vfs + signal.
954
- * BIND stays downward (see tests/contracts/layers.test.mjs). Do not split this
955
- * closure into pass-through files that re-import each other.
956
- */
957
17
  export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
958
18
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
959
19
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
@@ -1012,17 +72,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1012
72
  return env;
1013
73
  };
1014
74
 
1015
- if (!registry && pi && isFunction(pi.registerTool)) {
75
+ function captureHostTool(tool, excluded) {
76
+ return tool && isString(tool.name) && isFunction(tool.execute) && tool.name !== "supernova" && !excluded.has(tool.name);
77
+ }
78
+
79
+ function wrapHostRegister() {
80
+ if (registry || !pi || !isFunction(pi.registerTool)) return;
1016
81
  const original = pi.registerTool.bind(pi);
1017
82
  const excluded = new Set(config.excludeTools || []);
1018
83
  pi.registerTool = (tool) => {
1019
- if (
1020
- tool &&
1021
- isString(tool.name) &&
1022
- isFunction(tool.execute) &&
1023
- tool.name !== "supernova" &&
1024
- !excluded.has(tool.name)
1025
- ) {
84
+ if (captureHostTool(tool, excluded)) {
1026
85
  executors.set(tool.name, tool.execute.bind(tool));
1027
86
  definitions.set(tool.name, tool);
1028
87
  }
@@ -1031,18 +90,29 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1031
90
  };
1032
91
  }
1033
92
 
93
+ wrapHostRegister();
94
+
1034
95
  function bindCallContext(ctx, signal) {
1035
96
  activeCtx = ctx || null;
1036
97
  const sessionId = ctx?.sessionManager?.getSessionId?.();
1037
98
  boundSessionId = sessionId;
1038
99
  const registry = pi?.pi?.AgentRegistry?.global?.();
1039
- hostSession = sessionId && registry?.list
1040
- ? registry.list().map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
100
+ let sessions = [];
101
+
102
+ try { sessions = registry?.list?.() ?? []; } catch {}
103
+ if (!Array.isArray(sessions)) sessions = [];
104
+ hostSession = sessionId
105
+ ? sessions.map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
1041
106
  : null;
1042
107
  activeSignal = signal;
1043
108
  vfs.signal = signal;
1044
109
  }
1045
110
 
111
+ function evalToolNames() {
112
+ try { return hostSession?.getEvalBridgeToolNames?.() ?? []; }
113
+ catch { return []; }
114
+ }
115
+
1046
116
  function hostTool(name) {
1047
117
  if (!hostSession) return undefined;
1048
118
  const metadata = definitions.get(name);
@@ -1050,33 +120,46 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1050
120
  // Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
1051
121
  if (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
1052
122
 
1053
- return hostSession.getToolForEvalBridge?.(name);
123
+ try { return hostSession.getToolForEvalBridge?.(name); }
124
+ catch { return undefined; }
1054
125
  }
1055
126
 
1056
- function isCallable(name) {
1057
- if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
1058
-
1059
- if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
127
+ function callableEnv() {
128
+ return {
129
+ excluded: new Set(config.excludeTools || []),
130
+ hostSession,
131
+ natives,
132
+ executors,
133
+ sessionInvalid: () => hostSession && (hostSession.isDisposed || hostSession.sessionManager?.getSessionId?.() !== boundSessionId),
134
+ nativeOwned: name => Object.hasOwn(natives, name) && !executors.has(name)
135
+ && (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"),
136
+ evalAllows: name => {
137
+ if (!evalToolNames().includes(name) && definitions.has(name)) return false;
1060
138
 
1061
- // An internal adapter belongs to Supernova, not the host's visible tool list.
1062
- const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
1063
- && (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin");
1064
-
1065
- if (nativeOwned) return true;
139
+ return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
140
+ },
141
+ listed: name => {
142
+ let activeTools;
1066
143
 
1067
- if (hostSession) {
1068
- if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
144
+ try { activeTools = isFunction(pi?.getActiveTools) ? pi.getActiveTools() : undefined; } catch {}
1069
145
 
1070
- return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
1071
- }
146
+ if (definitions.has(name) && Array.isArray(activeTools) && !activeTools.includes(name)) return false;
1072
147
 
1073
- if (definitions.has(name) && isFunction(pi?.getActiveTools) && !pi.getActiveTools().includes(name)) return false;
148
+ return executors.has(name) || Object.hasOwn(natives, name);
149
+ },
150
+ hostTool,
151
+ };
152
+ }
1074
153
 
1075
- return executors.has(name) || Object.hasOwn(natives, name);
154
+ function isCallable(name) {
155
+ return toolIsCallable(name, callableEnv());
1076
156
  }
1077
157
 
1078
158
  function refreshTools() {
1079
- const tools = pi?.getAllTools?.() ?? [];
159
+ let listed = [];
160
+
161
+ try { listed = pi?.getAllTools?.() ?? []; } catch {}
162
+ const tools = Array.isArray(listed) ? listed : [];
1080
163
 
1081
164
  for (const tool of tools) {
1082
165
  if (!isString(tool?.name)) continue;
@@ -1098,7 +181,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1098
181
  callCount = 0;
1099
182
  trace = [];
1100
183
  // Files may change between programs (editor, git); never serve a stale run.
1101
- vfs.invalidateCache();
184
+ vfs.invalidateObserved();
1102
185
  clearPathCache();
1103
186
  }
1104
187
 
@@ -1130,8 +213,15 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1130
213
  } catch {}
1131
214
  }
1132
215
 
1133
- function checkCallBudget(name) {
216
+ function assertRunOpen(name) {
1134
217
  if (closed) throw new Error("program is already complete");
218
+
219
+ if (activeSignal?.aborted) throw new Error("aborted");
220
+
221
+ if (!isString(name) || !name) throw new Error("tool name required");
222
+ }
223
+
224
+ function chargeCallBudget() {
1135
225
  const maxCalls = config.maxBridgeCalls ?? 256;
1136
226
 
1137
227
  if (budget && ++budget.calls > maxCalls) throw new Error("host call budget exceeded (" + maxCalls + " calls per program batch): split the batch");
@@ -1142,10 +232,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1142
232
  `host call budget exceeded (${maxCalls} calls per program): split the work across programs`,
1143
233
  );
1144
234
  }
1145
-
1146
- if (activeSignal?.aborted) throw new Error("aborted");
1147
-
1148
- if (!isString(name) || !name) throw new Error("tool name required");
1149
235
  }
1150
236
 
1151
237
  function assertCallableTarget(name) {
@@ -1162,15 +248,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1162
248
  async function writeFallbackDiff(name, args) {
1163
249
  if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
1164
250
  const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
1165
- let previous = "";
1166
-
1167
- try {
1168
- previous = await vfs.read(target);
1169
- } catch (error) {
1170
- if (error?.code !== "ENOENT") throw error;
1171
- }
251
+ const { previous, removedLines } = await writeSnapshot(vfs, target, activeSignal);
1172
252
 
1173
- return buildWriteDiff(target, previous, args.content);
253
+ return removedLines === undefined ? buildWriteDiff(target, previous, args.content) : boundedWriteDiff(target, args.content, removedLines);
1174
254
  }
1175
255
 
1176
256
  function completeRecord(record, res, fallbackDiff) {
@@ -1181,64 +261,89 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1181
261
  notifyCall(record);
1182
262
  }
1183
263
 
1184
- async function invokeRaw(name, args) {
1185
- checkCallBudget(name);
1186
- const callId = ++sharedRegistry.callSeq;
1187
- assertCallableTarget(name);
264
+ function traceArgs(args) {
265
+ if (!isObject(args)) return {};
266
+ const out = {};
1188
267
 
1189
- if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
268
+ for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "op"]) {
269
+ const value = args[key];
1190
270
 
1191
- const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
1192
- const record = { name: command, adapter: name, args: args || {}, time: Date.now() };
1193
- trace.push(record);
1194
- notifyCall(record);
271
+ if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
272
+ // eslint-disable-next-line anti-slop/no-runtime-typeof -- display-only label: the value is already handled, this names its kind for the trace.
273
+ else if (Array.isArray(value)) out[key] = value.slice(0, 128).map(item => isString(item) ? truncateChars(item, 240, "trace").text : typeof item);
274
+ }
1195
275
 
1196
- try {
1197
- const delegated = hostTool(name);
1198
- const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
1199
- const argvOwned = name === "bash" && Array.isArray(args?.args) && process.platform !== "win32" && args.args.length === Object.keys(args.args).length && args.args.every(isString);
276
+ if (isString(args.content)) out.content = args.content.length + " chars";
277
+ if (Array.isArray(args.edits)) out.edits = args.edits.length + " edits";
278
+ if (Array.isArray(args.args)) out.args = args.args.length + " argv";
279
+
280
+ return out;
281
+ }
1200
282
 
1201
- if (exec && !argvOwned) {
1202
- if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
283
+ function assertOwnedOverride(name, args) {
284
+ if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
285
+ if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
286
+ }
1203
287
 
1204
- if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
1205
- const fallbackDiff = await writeFallbackDiff(name, args);
1206
- const mutating = isMutatingTool(name, config, args, definitions.get(name));
288
+ async function invokeOverride(target, name, args, record, callId) {
289
+ assertOwnedOverride(name, args);
290
+ const fallbackDiff = await writeFallbackDiff(name, args);
291
+ const mutating = isMutatingTool(name, config, args, definitions.get(name));
1207
292
 
1208
- if (mutating) await vfs.prepareExternalMutation(name);
293
+ if (mutating) await vfs.prepareExternalMutation(name);
294
+ if (activeSignal?.aborted || closed) throw new Error("aborted");
295
+ if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
1209
296
 
1210
- if (activeSignal?.aborted || closed) throw new Error("aborted");
297
+ try {
298
+ const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated
299
+ ? { ...activeCtx, settings: hostSession.settings, toolNames: evalToolNames(), autoApprove: false }
300
+ : activeCtx);
1211
301
 
1212
- if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
302
+ completeRecord(record, res, fallbackDiff);
1213
303
 
1214
- try {
1215
- const res = await exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, delegated
1216
- ? { ...activeCtx, settings: hostSession.settings, toolNames: hostSession.getEvalBridgeToolNames(), autoApprove: false }
1217
- : activeCtx);
304
+ return res;
305
+ } finally {
306
+ if (mutating) { vfs.invalidateObserved(); index.invalidate(); clearPathCache(); notifyWorkspaceChanged(); }
307
+ }
308
+ }
1218
309
 
1219
- completeRecord(record, res, fallbackDiff);
310
+ async function invokeNative(target, args, record) {
311
+ const res = await target.native(target.argvOwned ? { ...args, args: args.args.map(String) } : args || {}, activeSignal);
312
+ completeRecord(record, res);
1220
313
 
1221
- return res;
1222
- } finally {
1223
- if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); notifyWorkspaceChanged(); }
1224
- }
1225
- }
314
+ return res;
315
+ }
316
+
317
+ function failRecord(record, error) {
318
+ record.ok = false;
319
+ record.ms = Date.now() - record.time;
320
+ record.error = error instanceof Error ? error.message : String(error);
321
+ notifyCall(record);
322
+ }
323
+
324
+ async function invokeRaw(name, args) {
325
+ assertRunOpen(name);
326
+ const callId = ++sharedRegistry.callSeq;
327
+ assertCallableTarget(name);
1226
328
 
1227
- const native = Object.hasOwn(natives, name) ? natives[name] : undefined;
329
+ if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
330
+ // Refused calls are free: only charge the budget once a target will run.
331
+ chargeCallBudget();
332
+
333
+ const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
334
+ const record = { name: command, adapter: name, args: traceArgs(args), time: Date.now() };
335
+ trace.push(record);
336
+ notifyCall(record);
1228
337
 
1229
- if (native) {
1230
- const res = await native(args || {}, activeSignal);
1231
- completeRecord(record, res);
338
+ try {
339
+ const target = resolveInvokeTarget(name, args, { hostTool, hostSession, executors, natives });
1232
340
 
1233
- return res;
1234
- }
341
+ if (target.kind === "override") return await invokeOverride(target, name, args, record, callId);
342
+ if (target.kind === "native") return await invokeNative(target, args, record);
1235
343
 
1236
344
  throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
1237
345
  } catch (error) {
1238
- record.ok = false;
1239
- record.ms = Date.now() - record.time;
1240
- record.error = error instanceof Error ? error.message : String(error);
1241
- notifyCall(record);
346
+ failRecord(record, error);
1242
347
  throw error;
1243
348
  }
1244
349
  }
@@ -1253,7 +358,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1253
358
  ? res.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n")
1254
359
  : undefined;
1255
360
 
1256
- if (text) record.resultText = text;
361
+ if (text) record.resultText = truncateChars(text, 4096, "trace").text;
1257
362
  }
1258
363
 
1259
364
  async function call(name, args) {
@@ -1302,7 +407,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1302
407
  // Windows command shims need shell handling; preserve the existing route there.
1303
408
  supportsNativeArgv: () => process.platform !== "win32" && !hostTool("bash") && !executors.has("bash"),
1304
409
  summarizeEdit: (target, before, after, diff) => hooks.summarizeEdit(getCwd(), target, before, after, diff),
1305
- invalidateFiles() { vfs.invalidateCache(); index.invalidate(); clearPathCache(); },
410
+ invalidateFiles() { vfs.invalidateObserved(); index.invalidate(); clearPathCache(); },
411
+ describeMemory() {
412
+ const overlays = vfs.describeOverlays();
413
+
414
+ // No body cache: reads always hit disk, so retained VFS bytes are always zero.
415
+ return { vfsCacheBytes: 0, indexBytes: index.getEntryBytes(), overlayFiles: overlays.files, overlayBytes: overlays.bytes };
416
+ },
1306
417
  fileOperations: {
1307
418
  access: (target, mode) => fs.access(target, mode),
1308
419
  readFile: async target => Buffer.from(await vfs.read(target), "utf8"),
@@ -1327,7 +438,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1327
438
  beginSpeculation,
1328
439
  commitSpeculation,
1329
440
  rollbackSpeculation,
1330
- getVfsCacheSize: () => vfs.getCacheSize(),
1331
441
  getOverlayDepth: () => vfs.getOverlayDepth(),
1332
442
  call,
1333
443
  callMany,