pi-supernova 0.2.0 → 0.3.1

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 (32) hide show
  1. package/README.md +196 -196
  2. package/{CHANGELOG.md → docs/CHANGELOG.md} +54 -1
  3. package/index.js +81 -68
  4. package/package.json +12 -31
  5. package/{catalog.js → src/bridge/catalog.js} +7 -5
  6. package/{host-bridge.js → src/bridge/host-bridge.js} +232 -87
  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} +11 -9
  13. package/{search.js → src/context/search.js} +34 -1
  14. package/{snap.js → src/context/snap.js} +51 -31
  15. package/{surface.js → src/context/surface.js} +1 -1
  16. package/{diff.js → src/fs/diff.js} +12 -9
  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/{config.default.json → src/config/config.default.json} +0 -0
  29. /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
  30. /package/{ledger.js → src/context/ledger.js} +0 -0
  31. /package/{check.js → src/fs/check.js} +0 -0
  32. /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,9 +56,15 @@ function looksLikePath(target) {
55
56
  );
56
57
  }
57
58
 
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
+
58
65
  async function probeExistingPath(cwd, targetParam, vfs) {
59
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", true);
60
- if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return { path: targetPath, directory: false };
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
70
  return { path: targetPath, directory: st.isDirectory() };
@@ -115,14 +122,30 @@ async function formatLsEntry(dirPath, entry) {
115
122
  return formatDirectoryEntry(entry.name, typeLabel, size);
116
123
  }
117
124
 
118
- function createNativeAdapters(getCwd, vfs, config, index, ledger) {
119
- async function sourceRead(query, searchDir, signal) {
125
+ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
126
+ const reads = createNativeScheduler();
127
+ async function sourceRead(query, searchDir, signal, params = {}) {
120
128
  const cwd = getCwd();
121
129
  const includeHidden = path.relative(cwd, searchDir).split(path.sep)
122
130
  .some(segment => segment.startsWith(".") && segment.length > 1);
123
- const result = await executeSnap({ query, searchDir, root: cwd, includeHidden, index,
131
+ const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
132
+ pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
124
133
  overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
125
- return textResult(JSON.stringify(result, null, 2), { ...result, isSnap: true });
134
+ return openSource(result, params, signal);
135
+ }
136
+
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 });
126
149
  }
127
150
 
128
151
  async function readDirectory(dirPath, signal) {
@@ -150,28 +173,55 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
150
173
  const cwd = getCwd();
151
174
  const targetParam = params?.path ?? params?.target;
152
175
  if (Array.isArray(targetParam)) {
153
- const results = await Promise.all(targetParam.map(p => readAdapter({ ...params, path: p }, signal)));
154
- return textResult("", { count: results.length, batch: true, items: results.map(r => r.content[0].text) });
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 => {
179
+ try {
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
+ }
192
+
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);
155
197
  }
156
198
  const existing = await probeExistingPath(cwd, targetParam, vfs);
157
199
  if (existing) {
158
- if (!existing.directory) return readFile(existing.path, params);
159
- return isString(params?.about) ? sourceRead(params.about, existing.path, signal) : readDirectory(existing.path, signal);
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);
160
204
  }
161
- if (!looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal);
162
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
205
+ if (!looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
206
+ const targetPath = resolveReadPath(cwd, targetParam);
163
207
  return readFile(targetPath, params);
164
208
  }
165
209
 
166
210
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
167
- async function readFile(targetPath, params) {
211
+ async function readFile(targetPath, params, sourceLine) {
168
212
  const cwd = getCwd();
169
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
+ }
170
221
  const text = await vfs.read(targetPath);
171
222
  index.touch(rel);
172
223
  if (isString(params?.about)) {
173
- const pending = vfs.getOverlay(targetPath);
174
- const entry = pending === undefined ? index.entry(targetPath) : WorkspaceIndex.fromText(targetPath, pending);
224
+ const entry = WorkspaceIndex.fromText(targetPath, text);
175
225
  const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
176
226
  if (outline) {
177
227
  recordOutlineOrigins(rel, outline.text);
@@ -179,65 +229,115 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
179
229
  }
180
230
  }
181
231
  const explicit = isNumber(params?.offset) || isNumber(params?.limit);
182
- const firstLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
183
- 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
+ }
184
254
  ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
185
- 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 });
186
256
  }
187
257
 
188
258
  /**
189
259
  * The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
190
- * lines with numbers (so no verification re-read), a quick structural check, and every other
191
- * 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.
192
262
  */
193
- async function editSummary(cwd, target, original, updated, diff) {
263
+ async function editSummary(cwd, target, original, updated, diff, signal) {
194
264
  const rel = relativeSlash(cwd, target);
195
265
  const newLines = updated.split("\n");
196
- const added = diff.lines.filter((l) => l.type === "add");
197
- const first = added.length ? Math.max(1, added[0].lineNum - 2) : 1;
198
- const last = added.length ? Math.min(newLines.length, added[added.length - 1].lineNum + 2) : Math.min(newLines.length, first + 6);
199
- const window = [];
200
- for (let l = first; l <= last && window.length < 40; l++) window.push(String(l).padStart(5) + " " + newLines[l - 1]);
201
- ledger.recordOrigin(rel, first, newLines.slice(first - 1, first - 1 + window.length));
202
- 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");
203
284
  const check = quickCheck(updated, path.extname(target));
204
285
  if (check && !check.ok) out += `\ncheck: ${check.message}`;
205
- const refs = await changedDeclarationRefs(cwd, target, original, updated, diff);
286
+ const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
206
287
  if (refs) out += `\n${refs}`;
207
288
  return out;
208
289
  }
209
290
 
210
- async function changedDeclarationRefs(cwd, target, original, updated, diff) {
291
+ async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
211
292
  // Diff rows carry the replaced fragments; declarations live on whole file lines.
212
293
  const oldLines = original.split("\n");
213
294
  const newLines = updated.split("\n");
214
295
  const names = new Set();
296
+ const spans = new Map();
215
297
  for (const l of diff.lines) {
216
298
  if (l.type === "context") continue;
217
- 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] ?? "");
218
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;
219
308
  }
220
309
  if (names.size === 0) return "";
221
- const find = await referenceFinder(cwd, target);
222
- const parts = [];
223
- for (const name of [...names].slice(0, 3)) {
224
- const refs = find(name).filter((r) => !r.startsWith(relativeSlash(cwd, target) + ":"));
225
- 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;
226
322
  }
227
- return parts.join("\n");
228
323
  }
229
324
 
230
- 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;
231
326
 
232
- /** Source window (±2 lines, ► on the cited line) for one path:line, or null when it is outside the workspace/index. */
233
- function sourceWindow(cwd, file, lineNo) {
234
- const candidate = path.resolve(cwd, file);
235
- if (!candidate.startsWith(path.resolve(cwd) + path.sep)) return null;
236
- const entry = index.entry(candidate);
237
- if (!entry) return null;
238
- 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");
239
340
  if (lineNo < 1 || lineNo > raw.length) return null;
240
- const rel = relativeSlash(cwd, candidate);
241
341
  const start = Math.max(1, lineNo - 2);
242
342
  const rows = [];
243
343
  for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
@@ -246,16 +346,16 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
246
346
  }
247
347
 
248
348
  /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
249
- function sourceForReferences(cwd, output) {
349
+ async function sourceForReferences(cwd, commandCwd, output) {
250
350
  const seen = new Set();
251
351
  const blocks = [];
252
352
  for (const m of output.matchAll(SOURCE_REF)) {
253
353
  const key = m[1] + ":" + m[2];
254
354
  if (seen.has(key)) continue;
355
+ if (seen.size >= 4) break;
255
356
  seen.add(key);
256
- const block = sourceWindow(cwd, m[1], Number(m[2]));
357
+ const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
257
358
  if (block) blocks.push(block);
258
- if (blocks.length >= 4) break;
259
359
  }
260
360
  return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
261
361
  }
@@ -276,19 +376,20 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
276
376
 
277
377
  /** Where else a name appears (declaration line excluded), for outlines and edit results. */
278
378
  async function referenceFinder(cwd, targetPath) {
279
- const files = await index.files(cwd);
379
+ const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
280
380
  if (!index.canScan(files)) return () => [];
281
381
  return (name, excludeLine) => {
282
382
  if (!name || name.length < 3) return [];
283
383
  const escaped = name.replace(/[$]/g, (c) => "\\" + c);
284
384
  const regex = new RegExp("\\b" + escaped + "\\b");
285
385
  return index
286
- .grepRows(files, regex, cwd)
386
+ .grepRows(files, regex, cwd, file => vfs.getOverlay(file))
287
387
  .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
288
388
  .map((r) => r.rel + ":" + r.line);
289
389
  };
290
390
  }
291
391
 
392
+ hooks.summarizeEdit = editSummary;
292
393
  return {
293
394
  read: readAdapter,
294
395
  async write(params, signal) {
@@ -299,13 +400,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
299
400
  const content = params.content;
300
401
  let prevText = "";
301
402
  try {
302
- prevText = await vfs.read(target);
303
- } catch {}
403
+ prevText = await vfs.read(target, { preserveRead: true });
404
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
304
405
  const { speculative } = await vfs.write(target, content);
305
406
  index.touch(relativeSlash(cwd, target));
306
407
  const diff = buildWriteDiff(target, prevText, content);
307
408
  const tag = speculative ? " (speculative)" : "";
308
- 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 });
309
412
  },
310
413
  async edit(params, signal) {
311
414
  const cwd = getCwd();
@@ -323,7 +426,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
323
426
  matches.length === 1
324
427
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
325
428
  : buildMultiEditDiff(target, content, matches);
326
- const summary = await editSummary(cwd, target, content, updated, diff);
429
+ const summary = await editSummary(cwd, target, content, updated, diff, signal);
327
430
  return textResult(summary, { path: target, speculative, diff });
328
431
  },
329
432
  async apply_patch(params, signal) {
@@ -343,8 +446,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
343
446
  const { resultText, hunkCount } = applyPatchToText(original, params.patch);
344
447
  const { speculative } = await vfs.write(target, resultText);
345
448
  const diff = buildPatchDiff(target, params.patch);
346
- const tag = speculative ? " (speculative)" : "";
347
- 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, {
348
452
  path: target,
349
453
  hunks: hunkCount,
350
454
  speculative,
@@ -367,7 +471,6 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
367
471
  searchDir: snapTarget,
368
472
  root: cwd,
369
473
  includeHidden,
370
- index,
371
474
  overlayText: (p) => vfs.getOverlay(p),
372
475
  pendingPaths: vfs.getOverlayPaths(),
373
476
  signal,
@@ -397,30 +500,37 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
397
500
  },
398
501
  async bash(params, signal) {
399
502
  const cwd = getCwd();
400
- const command = unwrapIfFullyQuoted(String(params?.command ?? "").trim());
401
- 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");
402
507
  const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
403
508
 
404
- // Always shell. Splitting on spaces treated `git status` / quoted `exec("git status")`
405
- // as a single binary name (`bash: git status: command not found`).
406
- 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];
407
511
 
408
512
  const transactionBarrier = await vfs.prepareExternalMutation("bash");
409
513
  let res;
410
514
  try {
411
515
  res = await runCommand(argv, {
412
516
  cwd: targetCwd,
517
+ env: hooks.commandEnv(),
518
+ commandLabel: literal ? command : undefined,
413
519
  timeoutMs: params?.timeoutMs,
414
520
  signal,
415
521
  maxOutputChars: config.maxCallResultChars,
416
522
  });
523
+ } catch (error) {
524
+ if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message);
525
+ throw error;
417
526
  } finally {
418
527
  vfs.invalidateCache();
419
528
  index.invalidate();
529
+ clearPathCache();
420
530
  }
421
531
  const { stdout, stderr } = res;
422
532
  let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
423
- if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
533
+ if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
424
534
  return {
425
535
  content: [{ type: "text", text }],
426
536
  details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -486,12 +596,13 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
486
596
  export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
487
597
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
488
598
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
489
- const vfs = new CausalVfs(() => index.invalidate());
599
+ const vfs = new CausalVfs(() => index.invalidate(), target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
490
600
  const executors = registry?.executors ?? new Map();
491
601
  const definitions = registry?.definitions ?? new Map();
492
602
  const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
493
603
  let closed = false;
494
- const natives = createNativeAdapters(getCwd, vfs, config, index, ledger);
604
+ const hooks = {};
605
+ const natives = createNativeAdapters(getCwd, vfs, config, index, ledger, hooks);
495
606
  let callCount = 0;
496
607
  let activeCtx = null;
497
608
  let hostSession = null;
@@ -499,6 +610,22 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
499
610
  let activeSignal = undefined;
500
611
  let trace = [];
501
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
+ };
502
629
 
503
630
  if (!registry && pi && isFunction(pi.registerTool)) {
504
631
  const original = pi.registerTool.bind(pi);
@@ -622,7 +749,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
622
749
  callCount += 1;
623
750
  if (callCount > maxCalls) {
624
751
  throw new Error(
625
- `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`,
626
753
  );
627
754
  }
628
755
  if (activeSignal?.aborted) throw new Error("aborted");
@@ -634,7 +761,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
634
761
  const excluded = new Set(config.excludeTools || []);
635
762
  if (name === "supernova" || excluded.has(name)) {
636
763
  throw new Error(
637
- `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).`,
638
765
  );
639
766
  }
640
767
  }
@@ -664,7 +791,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
664
791
  assertCallableTarget(name);
665
792
  if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
666
793
 
667
- 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() };
668
796
  trace.push(record);
669
797
  notifyCall(record);
670
798
 
@@ -714,8 +842,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
714
842
 
715
843
  async function call(name, args) {
716
844
  if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
717
- const raw = await invokeRaw(name, args);
718
- 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);
719
848
  }
720
849
 
721
850
  async function callMany(calls) {
@@ -748,6 +877,21 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
748
877
  isCallable,
749
878
  externalNames,
750
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
+ },
751
895
  fork(options) {
752
896
  return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork() });
753
897
  },
@@ -756,6 +900,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
756
900
  resetCallBudget,
757
901
  getTrace,
758
902
  setCallListener,
903
+ barrier: run => scheduler.schedule("write", run, activeSignal),
759
904
  beginSpeculation,
760
905
  commitSpeculation,
761
906
  rollbackSpeculation,