pi-supernova 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +196 -173
  2. package/{CHANGELOG.md → docs/CHANGELOG.md} +57 -1
  3. package/index.js +88 -75
  4. package/package.json +12 -31
  5. package/{catalog.js → src/bridge/catalog.js} +9 -7
  6. package/{host-bridge.js → src/bridge/host-bridge.js} +280 -123
  7. package/src/bridge/native-tools.js +155 -0
  8. package/src/bridge/pi-extension.ts +2 -0
  9. package/{config.js → src/config/config.js} +1 -1
  10. package/{evidence.js → src/context/evidence.js} +23 -12
  11. package/{outline.js → src/context/outline.js} +1 -1
  12. package/{repo-index.js → src/context/repo-index.js} +25 -15
  13. package/{search.js → src/context/search.js} +34 -1
  14. package/src/context/snap.js +237 -0
  15. package/{surface.js → src/context/surface.js} +1 -1
  16. package/{diff.js → src/fs/diff.js} +7 -5
  17. package/{patch.js → src/fs/patch.js} +1 -1
  18. package/{vfs.js → src/fs/vfs.js} +55 -14
  19. package/{workspace.js → src/fs/workspace.js} +22 -6
  20. package/{bottleneck.js → src/output/bottleneck.js} +23 -6
  21. package/{format.js → src/output/format.js} +20 -1
  22. package/{guest-worker.js → src/runtime/guest-worker.js} +90 -21
  23. package/{parallel.js → src/runtime/parallel.js} +68 -1
  24. package/{runtime.js → src/runtime/runtime.js} +14 -5
  25. package/{omp-frame.js → src/ui/omp-frame.js} +1 -1
  26. package/{render-measure.js → src/ui/render-measure.js} +27 -1
  27. package/{render.js → src/ui/render.js} +42 -20
  28. package/snap.js +0 -248
  29. /package/{config.default.json → src/config/config.default.json} +0 -0
  30. /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
  31. /package/{ledger.js → src/context/ledger.js} +0 -0
  32. /package/{check.js → src/fs/check.js} +0 -0
  33. /package/{decode.js → src/shared/decode.js} +0 -0
@@ -1,23 +1,24 @@
1
1
 
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { packageHostResult, hostResultFailed } from "./bottleneck.js";
5
- import { isString, isNumber, isFunction, isObject } from "./decode.js";
6
- import { isMutatingTool, runParallelWave } from "./parallel.js";
4
+ import { homedir } from "node:os";
5
+ import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
6
+ import { isString, isNumber, isFunction, isObject } from "../shared/decode.js";
7
+ import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
7
8
  import { unknownToolMessage } from "./catalog.js";
8
- import { extractStructuralSurface } from "./surface.js";
9
- import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
- import { executeSnap } from "./snap.js";
11
- import { selectEvidence } from "./evidence.js";
12
- import { WorkspaceIndex } from "./repo-index.js";
13
- import { outlineFile } from "./outline.js";
14
- import { SeenLedger } from "./ledger.js";
15
- import { quickCheck } from "./check.js";
16
- import { declaredName } from "./repo-index.js";
17
- import { CausalVfs } from "./vfs.js";
18
- import { applyPatchToText } from "./patch.js";
19
- import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "./workspace.js";
20
- import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs } from "./search.js";
9
+ import { extractStructuralSurface } from "../context/surface.js";
10
+ import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
11
+ import { executeSnap } from "../context/snap.js";
12
+ import { selectEvidence } from "../context/evidence.js";
13
+ import { WorkspaceIndex } from "../context/repo-index.js";
14
+ import { outlineFile } from "../context/outline.js";
15
+ import { SeenLedger } from "../context/ledger.js";
16
+ import { quickCheck } from "../fs/check.js";
17
+ import { declaredName } from "../context/repo-index.js";
18
+ import { CausalVfs } from "../fs/vfs.js";
19
+ import { applyPatchToText } from "../fs/patch.js";
20
+ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
21
+ import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
21
22
 
22
23
  function textResult(text, details) {
23
24
  return {
@@ -55,15 +56,21 @@ function looksLikePath(target) {
55
56
  );
56
57
  }
57
58
 
58
- async function probeExistingFile(cwd, targetParam, vfs) {
59
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
60
- if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return targetPath;
59
+ function resolveReadPath(cwd, target) {
60
+ if (!isString(target) || !target.trim()) throw new Error("read requires path");
61
+ const input = target.trim();
62
+ return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
63
+ }
64
+
65
+ async function probeExistingPath(cwd, targetParam, vfs) {
66
+ const targetPath = resolveReadPath(cwd, targetParam);
67
+ if (vfs.getOverlay(targetPath) !== undefined) return { path: targetPath, directory: false };
61
68
  try {
62
69
  const st = await fs.stat(targetPath);
63
- if (st.isDirectory()) throw new Error(`read path is a directory, not a file: ${targetPath} (use ls)`);
64
- return targetPath;
70
+ return { path: targetPath, directory: st.isDirectory() };
65
71
  } catch (err) {
66
72
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
73
+ if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
67
74
  return null;
68
75
  }
69
76
  }
@@ -96,6 +103,11 @@ function applyReplacements(target, content, requestedEdits) {
96
103
  return { updated, matches };
97
104
  }
98
105
 
106
+ function formatDirectoryEntry(name, type, size = 0) {
107
+ const sizeSuffix = size ? `, ${size} bytes` : "";
108
+ return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
109
+ }
110
+
99
111
  async function formatLsEntry(dirPath, entry) {
100
112
  const isDir = entry.isDirectory();
101
113
  const isSym = entry.isSymbolicLink();
@@ -107,58 +119,109 @@ async function formatLsEntry(dirPath, entry) {
107
119
  size = st.size;
108
120
  }
109
121
  } catch {}
110
- const sizeSuffix = size ? `, ${size} bytes` : "";
111
- return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
122
+ return formatDirectoryEntry(entry.name, typeLabel, size);
112
123
  }
113
124
 
114
- function createNativeAdapters(getCwd, vfs, config, index, ledger) {
115
- async function readAdapter(params, signal) {
116
- signal?.throwIfAborted();
117
- const cwd = getCwd();
118
- const targetParam = params?.path ?? params?.target;
119
-
120
- if (Array.isArray(targetParam)) {
121
- const results = await Promise.all(
122
- targetParam.map((p) => readAdapter({ ...params, path: p }, signal)),
123
- );
124
- const items = results.map((r) => r.content[0].text);
125
- return textResult("", { count: results.length, batch: true, items });
126
- }
127
-
128
- if (looksLikePath(targetParam)) {
129
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
130
- return readFile(targetPath, params);
131
- }
125
+ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
126
+ const reads = createNativeScheduler();
127
+ async function sourceRead(query, searchDir, signal, params = {}) {
128
+ const cwd = getCwd();
129
+ const includeHidden = path.relative(cwd, searchDir).split(path.sep)
130
+ .some(segment => segment.startsWith(".") && segment.length > 1);
131
+ const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
132
+ pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
133
+ overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
134
+ return openSource(result, params, signal);
135
+ }
132
136
 
133
- const existing = await probeExistingFile(cwd, targetParam, vfs);
134
- if (existing) return readFile(existing, params);
137
+ async function openSource(result, params, signal) {
138
+ const cwd = getCwd();
139
+ if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
140
+ signal?.throwIfAborted();
141
+ const opened = await readFile(path.resolve(cwd, result.path), { ...params, about: undefined }, result.line);
142
+ const block = opened.content[0];
143
+ if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
144
+ const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
145
+ const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
146
+ text: block.text.slice(0, sourceChars), complete, nextOffset };
147
+ return textResult(params.resolve ? JSON.stringify(source) : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
148
+ { ...opened.details, isSnap: true });
149
+ }
150
+
151
+ async function readDirectory(dirPath, signal) {
152
+ signal?.throwIfAborted();
153
+ const rows = new Map();
154
+ for (const file of vfs.getOverlayPaths()) {
155
+ const relative = path.relative(dirPath, file);
156
+ if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
157
+ const [name, child] = relative.split(path.sep);
158
+ rows.set(name, child === undefined
159
+ ? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
160
+ : formatDirectoryEntry(name, "dir"));
161
+ }
162
+ let entries;
163
+ try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
164
+ if (error.code !== "ENOENT" || rows.size === 0) throw error;
165
+ entries = [];
166
+ }
167
+ for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
168
+ return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
169
+ }
135
170
 
136
- if (isString(targetParam) && targetParam.trim()) {
171
+ async function readAdapter(params, signal) {
172
+ signal?.throwIfAborted();
173
+ const cwd = getCwd();
174
+ const targetParam = params?.path ?? params?.target;
175
+ if (Array.isArray(targetParam)) {
176
+ if (targetParam.some(p => !isString(p) || !p.trim())) throw new Error("read paths must be non-empty strings");
177
+ if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
178
+ const results = await Promise.all(targetParam.map(async p => {
137
179
  try {
138
- const snapRes = await executeSnap({
139
- query: targetParam,
140
- searchDir: cwd,
141
- index,
142
- overlayText: (p) => vfs.getOverlay(p),
143
- pendingPaths: vfs.getOverlayPaths(),
144
- });
145
- return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
146
- } catch {}
147
- }
180
+ const block = (await readAdapter({ ...params, path: p }, signal)).content[0];
181
+ return { text: block.type === "image" ? block : block.text };
182
+ } catch (error) {
183
+ signal?.throwIfAborted();
184
+ return { text: `[read error: ${p}] ${error.message}`, error: { path: p, message: error.message } };
185
+ }
186
+ }));
187
+ signal?.throwIfAborted();
188
+ return 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) });
189
+ }
190
+ return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
191
+ }
148
192
 
149
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
150
- return readFile(targetPath, params);
193
+ async function readSingle(params, cwd, targetParam, signal) {
194
+ if (isString(params?.query)) {
195
+ const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
196
+ return sourceRead(params.query, scope, signal, params);
197
+ }
198
+ const existing = await probeExistingPath(cwd, targetParam, vfs);
199
+ if (existing) {
200
+ if (!existing.directory) return params.resolve
201
+ ? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
202
+ : readFile(existing.path, params);
203
+ return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
204
+ }
205
+ if (!looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
206
+ const targetPath = resolveReadPath(cwd, targetParam);
207
+ return readFile(targetPath, params);
151
208
  }
152
209
 
153
210
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
154
- async function readFile(targetPath, params) {
211
+ async function readFile(targetPath, params, sourceLine) {
155
212
  const cwd = getCwd();
156
213
  const rel = relativeSlash(cwd, targetPath);
214
+ const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
215
+ if (mime) {
216
+ if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
217
+ const staged = vfs.getOverlay(targetPath);
218
+ const bytes = staged === undefined ? await fs.readFile(targetPath) : Buffer.from(staged);
219
+ return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
220
+ }
157
221
  const text = await vfs.read(targetPath);
158
222
  index.touch(rel);
159
223
  if (isString(params?.about)) {
160
- const pending = vfs.getOverlay(targetPath);
161
- const entry = pending === undefined ? index.entry(targetPath) : WorkspaceIndex.fromText(targetPath, pending);
224
+ const entry = WorkspaceIndex.fromText(targetPath, text);
162
225
  const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
163
226
  if (outline) {
164
227
  recordOutlineOrigins(rel, outline.text);
@@ -166,65 +229,115 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
166
229
  }
167
230
  }
168
231
  const explicit = isNumber(params?.offset) || isNumber(params?.limit);
169
- const firstLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
170
- const sliced = sliceLines(text, params?.offset, params?.limit);
232
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
233
+ const offset = params?.offset ?? (sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1);
234
+ const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
235
+ const sliced = sliceLines(text, offset, params?.limit);
236
+ if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
237
+ let cap = budget - 160;
238
+ if (params.resolve) {
239
+ // Budget the actual JSON string, not a pessimistic fixed escape multiplier.
240
+ let low = 0, high = Math.max(0, cap);
241
+ while (low < high) {
242
+ const mid = Math.ceil((low + high) / 2);
243
+ if (JSON.stringify(sliced.slice(0, mid)).length <= budget - 160) low = mid;
244
+ else high = mid - 1;
245
+ }
246
+ cap = low;
247
+ }
248
+ const end = sliced.lastIndexOf("\n", cap);
249
+ if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
250
+ const body = sliced.slice(0, end + 1);
251
+ const next = firstLine + body.split("\n").length - 1;
252
+ 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 });
253
+ }
171
254
  ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
172
- return textResult(sliced, { path: targetPath });
255
+ return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + sliced.split("\n").length - 1 - Number(sliced.endsWith("\n")), sourceChars: sliced.length, complete: sliced === text });
173
256
  }
174
257
 
175
258
  /**
176
259
  * The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
177
- * lines with numbers (so no verification re-read), a quick structural check, and every other
178
- * place a changed declaration is referenced (so callers are not forgotten).
260
+ * lines with numbers, a quick structural check, and bounded lexical reference hints.
261
+ * These do not replace tests or semantic caller resolution.
179
262
  */
180
- async function editSummary(cwd, target, original, updated, diff) {
263
+ async function editSummary(cwd, target, original, updated, diff, signal) {
181
264
  const rel = relativeSlash(cwd, target);
182
265
  const newLines = updated.split("\n");
183
- const added = diff.lines.filter((l) => l.type === "add");
184
- const first = added.length ? Math.max(1, added[0].lineNum - 2) : 1;
185
- const last = added.length ? Math.min(newLines.length, added[added.length - 1].lineNum + 2) : Math.min(newLines.length, first + 6);
186
- const window = [];
187
- for (let l = first; l <= last && window.length < 40; l++) window.push(String(l).padStart(5) + " " + newLines[l - 1]);
188
- ledger.recordOrigin(rel, first, newLines.slice(first - 1, first - 1 + window.length));
189
- let out = `edited ${rel}:${first}–${first + window.length - 1}\n${window.join("\n")}`;
266
+ const ranges = [];
267
+ const positions = diff.lines.filter(row => row.type !== "context")
268
+ .map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
269
+ for (const line of positions) {
270
+ const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
271
+ if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
272
+ else ranges.push({ start, end });
273
+ }
274
+ const blocks = [];
275
+ const perRange = Math.max(1, Math.floor(40 / Math.max(1, ranges.length)));
276
+ for (const { start, end } of ranges) {
277
+ const last = Math.min(end, start + perRange - 1);
278
+ const lines = newLines.slice(start - 1, last);
279
+ ledger.recordOrigin(rel, start, lines);
280
+ blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
281
+ if (last < end) blocks.push("[continue with read({path:" + JSON.stringify(rel) + ",offset:" + (last + 1) + ",limit:" + (end - last) + "})]");
282
+ }
283
+ let out = blocks.join("\n");
190
284
  const check = quickCheck(updated, path.extname(target));
191
285
  if (check && !check.ok) out += `\ncheck: ${check.message}`;
192
- const refs = await changedDeclarationRefs(cwd, target, original, updated, diff);
286
+ const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
193
287
  if (refs) out += `\n${refs}`;
194
288
  return out;
195
289
  }
196
290
 
197
- async function changedDeclarationRefs(cwd, target, original, updated, diff) {
291
+ async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
198
292
  // Diff rows carry the replaced fragments; declarations live on whole file lines.
199
293
  const oldLines = original.split("\n");
200
294
  const newLines = updated.split("\n");
201
295
  const names = new Set();
296
+ const spans = new Map();
202
297
  for (const l of diff.lines) {
203
298
  if (l.type === "context") continue;
204
- const name = declaredName((l.type === "remove" ? oldLines : newLines)[l.lineNum - 1] ?? "");
299
+ const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
300
+ const name = declaredName((l.type === "remove" ? oldLines : newLines)[number - 1] ?? "");
205
301
  if (name) names.add(name);
302
+ else {
303
+ if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
304
+ const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
305
+ if (owner?.name) names.add(owner.name);
306
+ }
307
+ if (names.size >= 3) break;
206
308
  }
207
309
  if (names.size === 0) return "";
208
- const find = await referenceFinder(cwd, target);
209
- const parts = [];
210
- for (const name of [...names].slice(0, 3)) {
211
- const refs = find(name).filter((r) => !r.startsWith(relativeSlash(cwd, target) + ":"));
212
- if (refs.length) parts.push(`${name} also referenced in ${refs.slice(0, 6).join(", ")}${refs.length > 6 ? " (+" + (refs.length - 6) + ")" : ""}`);
310
+ try {
311
+ const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
312
+ excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
313
+ const parts = [];
314
+ for (const [name, refs] of references) {
315
+ if (refs.length) parts.push(name + " also referenced in " + refs.slice(0, 6).join(", ") + (refs.length > 6 ? " (more matches)" : ""));
316
+ }
317
+ if (incomplete) parts.push("references incomplete: search budget reached");
318
+ return parts.join("\n");
319
+ } catch (error) {
320
+ signal?.throwIfAborted();
321
+ return "references unavailable: " + error.message;
213
322
  }
214
- return parts.join("\n");
215
323
  }
216
324
 
217
- const SOURCE_REF = /((?:[\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;
325
+ 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;
218
326
 
219
- /** Source window (±2 lines, ► on the cited line) for one path:line, or null when it is outside the workspace/index. */
220
- function sourceWindow(cwd, file, lineNo) {
221
- const candidate = path.resolve(cwd, file);
222
- if (!candidate.startsWith(path.resolve(cwd) + path.sep)) return null;
223
- const entry = index.entry(candidate);
224
- if (!entry) return null;
225
- const { raw } = WorkspaceIndex.linesOf(entry);
327
+ /** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
328
+ async function sourceWindow(cwd, commandCwd, file, lineNo) {
329
+ const candidate = path.resolve(commandCwd, file);
330
+ let text, rel;
331
+ try {
332
+ const root = await fs.realpath(cwd);
333
+ if (!candidate.startsWith(path.resolve(cwd) + path.sep) && !candidate.startsWith(root + path.sep)) return null;
334
+ const real = await fs.realpath(candidate);
335
+ if (!real.startsWith(root + path.sep) || (await fs.stat(real)).size > 1024 * 1024) return null;
336
+ text = await fs.readFile(real, "utf8");
337
+ rel = relativeSlash(root, real);
338
+ } catch { return null; }
339
+ const raw = text.split("\n");
226
340
  if (lineNo < 1 || lineNo > raw.length) return null;
227
- const rel = relativeSlash(cwd, candidate);
228
341
  const start = Math.max(1, lineNo - 2);
229
342
  const rows = [];
230
343
  for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
@@ -233,16 +346,16 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
233
346
  }
234
347
 
235
348
  /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
236
- function sourceForReferences(cwd, output) {
349
+ async function sourceForReferences(cwd, commandCwd, output) {
237
350
  const seen = new Set();
238
351
  const blocks = [];
239
352
  for (const m of output.matchAll(SOURCE_REF)) {
240
353
  const key = m[1] + ":" + m[2];
241
354
  if (seen.has(key)) continue;
355
+ if (seen.size >= 4) break;
242
356
  seen.add(key);
243
- const block = sourceWindow(cwd, m[1], Number(m[2]));
357
+ const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
244
358
  if (block) blocks.push(block);
245
- if (blocks.length >= 4) break;
246
359
  }
247
360
  return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
248
361
  }
@@ -263,19 +376,20 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
263
376
 
264
377
  /** Where else a name appears (declaration line excluded), for outlines and edit results. */
265
378
  async function referenceFinder(cwd, targetPath) {
266
- const files = await index.files(cwd);
379
+ const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
267
380
  if (!index.canScan(files)) return () => [];
268
381
  return (name, excludeLine) => {
269
382
  if (!name || name.length < 3) return [];
270
383
  const escaped = name.replace(/[$]/g, (c) => "\\" + c);
271
384
  const regex = new RegExp("\\b" + escaped + "\\b");
272
385
  return index
273
- .grepRows(files, regex, cwd)
386
+ .grepRows(files, regex, cwd, file => vfs.getOverlay(file))
274
387
  .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
275
388
  .map((r) => r.rel + ":" + r.line);
276
389
  };
277
390
  }
278
391
 
392
+ hooks.summarizeEdit = editSummary;
279
393
  return {
280
394
  read: readAdapter,
281
395
  async write(params, signal) {
@@ -286,13 +400,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
286
400
  const content = params.content;
287
401
  let prevText = "";
288
402
  try {
289
- prevText = await vfs.read(target);
290
- } catch {}
403
+ prevText = await vfs.read(target, { preserveRead: true });
404
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
291
405
  const { speculative } = await vfs.write(target, content);
292
406
  index.touch(relativeSlash(cwd, target));
293
407
  const diff = buildWriteDiff(target, prevText, content);
294
408
  const tag = speculative ? " (speculative)" : "";
295
- return textResult(`wrote ${target}${tag}`, { path: target, speculative, diff });
409
+ const check = quickCheck(content, path.extname(target));
410
+ const warning = check && !check.ok ? "\ncheck: " + check.message : "";
411
+ return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
296
412
  },
297
413
  async edit(params, signal) {
298
414
  const cwd = getCwd();
@@ -310,7 +426,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
310
426
  matches.length === 1
311
427
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
312
428
  : buildMultiEditDiff(target, content, matches);
313
- const summary = await editSummary(cwd, target, content, updated, diff);
429
+ const summary = await editSummary(cwd, target, content, updated, diff, signal);
314
430
  return textResult(summary, { path: target, speculative, diff });
315
431
  },
316
432
  async apply_patch(params, signal) {
@@ -330,8 +446,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
330
446
  const { resultText, hunkCount } = applyPatchToText(original, params.patch);
331
447
  const { speculative } = await vfs.write(target, resultText);
332
448
  const diff = buildPatchDiff(target, params.patch);
333
- const tag = speculative ? " (speculative)" : "";
334
- return textResult(`applied ${hunkCount} hunk(s) to ${target}${tag}`, {
449
+ index.touch(relativeSlash(cwd, target));
450
+ const summary = await editSummary(cwd, target, original, resultText, diff, signal);
451
+ return textResult(summary, {
335
452
  path: target,
336
453
  hunks: hunkCount,
337
454
  speculative,
@@ -354,9 +471,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
354
471
  searchDir: snapTarget,
355
472
  root: cwd,
356
473
  includeHidden,
357
- index,
358
474
  overlayText: (p) => vfs.getOverlay(p),
359
475
  pendingPaths: vfs.getOverlayPaths(),
476
+ signal,
360
477
  });
361
478
  return textResult(JSON.stringify(res, null, 2), res);
362
479
  },
@@ -383,30 +500,37 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
383
500
  },
384
501
  async bash(params, signal) {
385
502
  const cwd = getCwd();
386
- const command = unwrapIfFullyQuoted(String(params?.command ?? "").trim());
387
- if (!command) throw new Error("bash requires command");
503
+ const literal = params?._directArgv === true;
504
+ 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");
505
+ const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
506
+ if (!command.trim()) throw new Error("bash requires command");
388
507
  const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
389
508
 
390
- // Always shell. Splitting on spaces treated `git status` / quoted `exec("git status")`
391
- // as a single binary name (`bash: git status: command not found`).
392
- const argv = ["bash", "-c", command];
509
+ // String commands keep shell semantics; literal argv needs no quoting or shell startup.
510
+ const argv = literal ? [command, ...params.args] : ["bash", "-c", command];
393
511
 
394
512
  const transactionBarrier = await vfs.prepareExternalMutation("bash");
395
513
  let res;
396
514
  try {
397
515
  res = await runCommand(argv, {
398
516
  cwd: targetCwd,
517
+ env: hooks.commandEnv(),
518
+ commandLabel: literal ? command : undefined,
399
519
  timeoutMs: params?.timeoutMs,
400
520
  signal,
401
521
  maxOutputChars: config.maxCallResultChars,
402
522
  });
523
+ } catch (error) {
524
+ if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message);
525
+ throw error;
403
526
  } finally {
404
527
  vfs.invalidateCache();
405
528
  index.invalidate();
529
+ clearPathCache();
406
530
  }
407
531
  const { stdout, stderr } = res;
408
532
  let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
409
- if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
533
+ if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
410
534
  return {
411
535
  content: [{ type: "text", text }],
412
536
  details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -464,13 +588,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
464
588
  async ls(params, signal) {
465
589
  const cwd = getCwd();
466
590
  const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
467
- if (signal?.aborted) throw new Error("aborted");
468
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
469
- const lines = [];
470
- for (const entry of entries) {
471
- lines.push(await formatLsEntry(dirPath, entry));
472
- }
473
- return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
591
+ return readDirectory(dirPath, signal);
474
592
  },
475
593
  };
476
594
  }
@@ -478,12 +596,13 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
478
596
  export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
479
597
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
480
598
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
481
- const vfs = new CausalVfs(() => index.invalidate());
599
+ const vfs = new CausalVfs(() => index.invalidate(), target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
482
600
  const executors = registry?.executors ?? new Map();
483
601
  const definitions = registry?.definitions ?? new Map();
484
602
  const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
485
603
  let closed = false;
486
- const natives = createNativeAdapters(getCwd, vfs, config, index, ledger);
604
+ const hooks = {};
605
+ const natives = createNativeAdapters(getCwd, vfs, config, index, ledger, hooks);
487
606
  let callCount = 0;
488
607
  let activeCtx = null;
489
608
  let hostSession = null;
@@ -491,6 +610,22 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
491
610
  let activeSignal = undefined;
492
611
  let trace = [];
493
612
  let callListener = null;
613
+ const scheduler = createNativeScheduler();
614
+ hooks.commandEnv = () => {
615
+ const env = { ...process.env };
616
+ const current = {
617
+ PI_SESSION_ID: activeCtx?.sessionManager?.getSessionId?.(),
618
+ PI_SESSION_FILE: activeCtx?.sessionManager?.getSessionFile?.(),
619
+ PI_PROVIDER: activeCtx?.model?.provider,
620
+ PI_MODEL: activeCtx?.model?.id,
621
+ PI_REASONING_LEVEL: activeCtx?.thinkingLevel,
622
+ };
623
+ for (const [key, value] of Object.entries(current)) {
624
+ if (isString(value)) env[key] = value;
625
+ else delete env[key];
626
+ }
627
+ return env;
628
+ };
494
629
 
495
630
  if (!registry && pi && isFunction(pi.registerTool)) {
496
631
  const original = pi.registerTool.bind(pi);
@@ -532,8 +667,12 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
532
667
 
533
668
  function isCallable(name) {
534
669
  if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
670
+ if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
671
+ // An internal adapter belongs to Supernova, not the host's visible tool list.
672
+ const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
673
+ && (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin");
674
+ if (nativeOwned) return true;
535
675
  if (hostSession) {
536
- if (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId) return false;
537
676
  if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
538
677
  return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
539
678
  }
@@ -610,7 +749,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
610
749
  callCount += 1;
611
750
  if (callCount > maxCalls) {
612
751
  throw new Error(
613
- `host call budget exceeded (${maxCalls} calls per program): batch with read([paths]) or nova.callMany, or split the work across programs`,
752
+ `host call budget exceeded (${maxCalls} calls per program): split the work across programs`,
614
753
  );
615
754
  }
616
755
  if (activeSignal?.aborted) throw new Error("aborted");
@@ -622,7 +761,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
622
761
  const excluded = new Set(config.excludeTools || []);
623
762
  if (name === "supernova" || excluded.has(name)) {
624
763
  throw new Error(
625
- `nova.call("${name}") is blocked (excluded / non-reentrant). Use nova.search/describe for discovery, or call a concrete host tool.`,
764
+ `${name} is blocked (excluded / non-reentrant).`,
626
765
  );
627
766
  }
628
767
  }
@@ -652,7 +791,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
652
791
  assertCallableTarget(name);
653
792
  if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
654
793
 
655
- const record = { name, args: args || {}, time: Date.now() };
794
+ const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
795
+ const record = { name: command, adapter: name, args: args || {}, time: Date.now() };
656
796
  trace.push(record);
657
797
  notifyCall(record);
658
798
 
@@ -702,8 +842,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
702
842
 
703
843
  async function call(name, args) {
704
844
  if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
705
- const raw = await invokeRaw(name, args);
706
- return packageHostResult(raw, config);
845
+ const invoke = async () => packageHostResult(await invokeRaw(name, args), config);
846
+ const kind = isMutatingTool(name, config, args, definitions.get(name)) ? "write" : "read";
847
+ return scheduler.schedule(kind, invoke, activeSignal);
707
848
  }
708
849
 
709
850
  async function callMany(calls) {
@@ -736,6 +877,21 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
736
877
  isCallable,
737
878
  externalNames,
738
879
  supportsBatchRead: () => !hostTool("read") && !executors.has("read"),
880
+ // Windows command shims need shell handling; preserve the existing route there.
881
+ supportsNativeArgv: () => process.platform !== "win32" && !hostTool("bash") && !executors.has("bash"),
882
+ summarizeEdit: (target, before, after, diff) => hooks.summarizeEdit(getCwd(), target, before, after, diff),
883
+ invalidateFiles() { vfs.invalidateCache(); index.invalidate(); clearPathCache(); },
884
+ fileOperations: {
885
+ access: (target, mode) => fs.access(target, mode),
886
+ readFile: async target => Buffer.from(await vfs.read(target), "utf8"),
887
+ // VFS owns parent creation and atomic replacement, inside Pi's file queue.
888
+ mkdir: async () => {},
889
+ async writeFile(target, content) {
890
+ await resolveWorkspacePath(getCwd(), target, "write", false);
891
+ await vfs.write(target, content);
892
+ index.touch(relativeSlash(getCwd(), target));
893
+ },
894
+ },
739
895
  fork(options) {
740
896
  return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork() });
741
897
  },
@@ -744,6 +900,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
744
900
  resetCallBudget,
745
901
  getTrace,
746
902
  setCallListener,
903
+ barrier: run => scheduler.schedule("write", run, activeSignal),
747
904
  beginSpeculation,
748
905
  commitSpeculation,
749
906
  rollbackSpeculation,