pi-supernova 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,6 +109,68 @@ options, not mandatory stages of source resolution. Ordinary calls also get:
109
109
  Distant edit regions have separate windows and continuation pointers for omitted
110
110
  lines. Structural warnings and source windows are not substitutes for tests.
111
111
 
112
+ ### Safe read-modify-write
113
+
114
+ Plain reads are bounded views, not guaranteed full-file buffers. Use
115
+ `read({path:"file.txt",complete:true})` when code needs the complete file; it
116
+ throws rather than handing back partial text. Prefer `edit` for large-file
117
+ replacements, or reconstruct exact `resolve:true` windows before writing.
118
+ Writes reject Supernova truncation markers, including legacy host-result markers.
119
+ For intentionally writing literal marker documentation only, opt in with
120
+ `write({path,content,allowReadArtifacts:true})`. This is a data-loss guard, not
121
+ full dataflow tracking or a security sandbox.
122
+
123
+ Explicit read arrays reject missing/failed paths. For typed partial outcomes use
124
+ `Promise.allSettled(paths.map(path => read(path)))`. Successful arrays remain arrays.
125
+ For embedded source with backslash escapes, use `String.raw` template literals
126
+ (and escape delimiter backticks), or JSON-quoted strings. Validate generated code.
127
+
128
+ On hosts exposing `sessionManager.getArtifactsDir()`, bare `agent://<id>` and
129
+ `artifact://<number>` read files from the calling session's artifact directory.
130
+ They preserve ID casing and support offset/limit and structured continuation.
131
+ These resources are read-only; ambiguous artifact IDs and escaping symlinks fail.
132
+ This is not the full OMP URI language: cross-session search, nested path/query
133
+ selectors and other schemes are not implemented. Hosts without an artifact
134
+ directory report that limitation rather than treating the URI as a local path.
135
+
136
+ For unstructured logs/text, `read(path,{about:"STT database"})` returns bounded,
137
+ line-numbered matching windows, or explicitly reports no matching text. It is not
138
+ a complete-file read. Write temporary investigation files under the workspace
139
+ (e.g. `.work/probe.py`): ordinary `write`/`edit` paths cannot escape it, including
140
+ absolute `/tmp` paths. Shell execution is a separate trusted boundary, not a sandbox.
141
+
142
+ ### Large inputs and report outputs
143
+
144
+ The default program limit is 48,000 UTF-16 code units (configurable via
145
+ `maxCodeChars` and exposed in the tool schema). Split larger documents into
146
+ separate invocations: first `write(path, firstChunk)`, then
147
+ `write({path,content:nextChunk,append:true})`. Append uses the complete internal
148
+ file buffer, never a bounded model-facing read; it retains conflict checks and
149
+ per-program rollback. Missing files are created. Multiple invocations are not
150
+ one atomic transaction: for an all-or-nothing publication, assemble a new staging
151
+ file and publish it only when complete. External write overrides reject append.
152
+
153
+ Supernova is a bounded foreground executor, not a durable background-job manager.
154
+ For long archive scans, use resumable chunks or a host background-job tool and write
155
+ progress records under `.work`. Set the inner `bash` timeout shorter than the
156
+ outer program timeout (for example 10 seconds inside a 20-second program) to retain
157
+ bounded shell diagnostics. A hard guest deadline cannot guarantee pending shell
158
+ output delivery; progress files survive shell execution but staged VFS writes may
159
+ roll back.
160
+
161
+ Large returned objects are bounded previews, not retained artifacts. Select fields
162
+ and array windows before returning, rather than parsing a truncated preview:
163
+
164
+ ```js
165
+ const report = JSON.parse(await read({path:"report.json",complete:true}));
166
+ return {verdict:report.verdict, values:report.values.slice(5000,5003)};
167
+ ```
168
+
169
+ If the raw JSON exceeds the read budget, reconstruct exact source windows or run
170
+ a bounded parser through `bash`. There is no implicit continuation handle for
171
+ arbitrary guest objects. For embedded code, JSON-encode the source string once;
172
+ do not nest shell, JavaScript, and Python quoting unless it is necessary.
173
+
112
174
  ## Execution and automatic batching
113
175
 
114
176
  Compatible independently started reads coalesce at the worker/host boundary.
@@ -161,6 +223,32 @@ Default limits are in `src/config/config.default.json`. Configuration loads from
161
223
  Text limits are character budgets, not tokenizer counts. `/supernova` reports
162
224
  programs and output characters without labelling characters as tokens.
163
225
 
226
+ ## Optional workspace change notifications
227
+
228
+ Supernova works without another search package. On hosts exposing `pi.events`, it
229
+ provides an advisory `workspace:changed` event for independent cache/index consumers:
230
+
231
+ ```js
232
+ { version: 1, cwd: "/absolute/workspace", paths: ["/absolute/workspace/file.js"] }
233
+ ```
234
+
235
+ `cwd` identifies the calling workspace. `paths` contains absolute file paths after
236
+ a successful disk flush; paths may contain filesystem aliases. The frozen event
237
+ and path array contain no source text. Checkpoint merges, restored rollbacks, and
238
+ read-only programs emit nothing. A shell boundary can flush paths before a later
239
+ program failure, so notifications are not conditional on overall tool success.
240
+
241
+ `paths: null` means the changed paths are unknown: shell or delegated mutation
242
+ attempts emit this even on failure, as does an incomplete commit recovery. Consumers
243
+ should invalidate conservatively, not interpret it as an empty change list or a
244
+ guarantee that shell effects stayed inside `cwd`.
245
+
246
+ Subscribe with `pi.events.on("workspace:changed", handler)`. Mark cached state dirty
247
+ synchronously, then refresh when queried; asynchronous listeners are not awaited.
248
+ Observer failures cannot roll back writes. This is an optional extension convention,
249
+ not a built-in host standard, durable event log, or cross-process filesystem watcher.
250
+ There is no dependency on or automatic routing to any consumer package.
251
+
164
252
  ## Security and host boundary
165
253
 
166
254
  CodeMode executes trusted JavaScript in a terminable worker, **not a security
@@ -182,6 +270,16 @@ cover batching fidelity, image/context retention, checkpoints, mutation ordering
182
270
  external symlinks, deadlines, worker isolation and execution-context environment.
183
271
  The former deleted suite has not been silently reinstated.
184
272
 
273
+ Test user-visible contracts through registered programs: exact source, on-disk
274
+ results, failure/rollback, isolation, bounded output, and usable host rendering.
275
+ Inject filesystem faults only to exercise real failure paths; do not prescribe
276
+ private helper layouts, staging filenames, or syscall counts. New regressions must
277
+ fail before the fix; for existing behavior, verify that a named deliberate defect
278
+ makes the intended test fail before accepting it. Keep the original 12 acceptance
279
+ tests unchanged. Cost gates cover avoidable search processes, per-read budgets and
280
+ progress flooding; latency claims belong in the explicit measurement lane, not
281
+ arbitrary wall-clock assertions.
282
+
185
283
  ```bash
186
284
  npm test --prefix packages/pi-supernova
187
285
  npm run lint:supernova
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2] - 2026-09-07
4
+
5
+ - Focus `read(path,{about})` on matching line windows in unstructured logs/text instead of returning a truncated unrelated prefix; report no matches explicitly.
6
+ - Create recursive source watchers as non-persistent so Linux/Node 24 hosts can exit naturally; add a child-process regression verified on Spark.
7
+ - Expose the configured program-length cap in the schema and add transactional `write({path,content,append:true})` for large-document chunks without bounded read-back. Document typed partial reads, embedded-source quoting and bounded report projections.
8
+ - Emit optional, producer-independent `workspace:changed` v1 notifications at disk commit boundaries, with conservative unknown-path invalidation for external mutations. Staging and restored rollbacks emit nothing; observer failures cannot break writes.
9
+
10
+ - Refuse writes containing truncated-read payload markers; add `read({path,complete:true})` for fail-closed full-file reads. Preserve ordinary bounded views and exact source continuation.
11
+ - Explicit read arrays now reject failed paths; use `Promise.allSettled` over independent reads for typed partial results.
12
+ - Resolve bare `agent://` and `artifact://` IDs from the calling host session’s artifact directory, with read-only scope, ambiguity checks and bounded pagination. This does not implement the full host URI language.
13
+ - Document embedded-source escaping and add regressions for the reported read/write corruption, batch errors and session-resource isolation, including actual OMP execution.
14
+
15
+ ## [0.3.1] - 2026-09-06
16
+
17
+ ### Fixed
18
+
19
+ - Preserve post-edit coordinates for patch deletions, including zero-length new ranges. After an earlier hunk inserts lines, the edit result now opens the actual deletion region rather than an unrelated earlier source window. File mutation semantics are unchanged.
20
+
21
+ ### Tests
22
+
23
+ - Add a focused shifted-deletion regression and an opt-in stress runner that can target an isolated npm installation. Cover concurrent programs, 129-read batches, contended writes, cancellation after confirmed staging, immutable progress frames, source fidelity and cold lookup across 5,000 files.
24
+
3
25
  ## [0.3.0] - 2026-09-05
4
26
 
5
27
  ### Source operations
package/index.js CHANGED
@@ -90,14 +90,15 @@ read("symbol or question") → locate and open source in one call, without an in
90
90
  read({query, resolve:true}) → {status,path,line,lines,text,complete,nextOffset?} for a direct resolve→edit handoff
91
91
  read(path, {about: question}) → relevant file bodies, or source selection inside a directory
92
92
  read({query, evidence:true}) → ranked evidence; read({path, outline:true}) → structural declarations
93
- write(path, text) → write a file
93
+ write(path, text) → write a file; write({path,content,append:true}) appends a chunk without a bounded read
94
94
  edit(path, oldText, newText) → post-edit lines, checks, and references
95
95
  edit(async () => {...}) → filesystem checkpoint: commit on success, rollback on throw; no shell commands, nesting, or concurrent outside commands
96
96
  bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
97
97
  bash({command, args:[...]}) → literal argv without shell expansion of arguments
98
98
 
99
99
  Only found selects and opens a file. Uncertain reads return ambiguous, not_found, or incomplete with no selected path. Use resolve:true for structured status checks; narrow the directory with path+about when uncertain.
100
- Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
100
+ For read-modify-write, use read({path,complete:true}); it rejects partial output. Prefer edit for large files. Array reads reject failures; use Promise.allSettled for per-path outcomes.
101
+ Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?, complete?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
101
102
  Independent read starts batch automatically. Mutations preserve submission order. Plain reads remain self-contained; oversized reads provide continuation offsets. Return only what the model needs. console.log is captured.`;
102
103
 
103
104
  export default function piSupernova(pi) {
@@ -152,7 +153,7 @@ export function registerCodeMode(pi) {
152
153
  "Use read, write, edit, and bash inside supernova. Start with read(question), or read(directory, {about: question}) for scoped source selection. A source question already opens the selected file; do not issue a redundant read. Use read({query,resolve:true}) and check status before editing its path. Explicit about/outline/evidence reads remain available when needed. Return a compact value.",
153
154
  ],
154
155
  parameters: Type.Object({
155
- code: Type.String({ description: "JavaScript program: async body or arrow function." }),
156
+ code: Type.String({ maxLength: config.maxCodeChars ?? 48000, description: `JavaScript program: async body or arrow function. Maximum ${config.maxCodeChars ?? 48000} UTF-16 code units; split large writes into write({path,content,append:true}) chunks.` }),
156
157
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
157
158
  }, { required: ["code"] }),
158
159
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "One CodeMode invocation for Pi and OMP, with four guest commands, automatic read batching and source context.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -13,11 +13,12 @@ const NATIVE_TOOL_DEFINITIONS = [
13
13
  about: { type: "string", description: "Question or symbol: expand file bodies, or locate and open source inside a directory" },
14
14
  query: { type: "string", description: "Source question; optional path scopes the search directory" },
15
15
  resolve: { type: "boolean", description: "Return structured source/status for a direct resolve-to-edit handoff" },
16
+ complete: { type: "boolean", description: "Fail unless the entire requested file fits without clipping" },
16
17
  } },
17
18
  },
18
19
  {
19
20
  name: "write", description: "Write UTF-8 content to a workspace file.",
20
- parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] },
21
+ parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" }, allowReadArtifacts: { type: "boolean", description: "Explicit opt-in for intentionally writing literal truncation-marker text" } }, required: ["path", "content"] },
21
22
  },
22
23
  {
23
24
  name: "edit", description: "Apply unique text replacements to a workspace file; returns the post-edit lines, a structural check, and references to changed declarations.",
@@ -134,11 +134,11 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
134
134
  return openSource(result, params, signal);
135
135
  }
136
136
 
137
- async function openSource(result, params, signal) {
137
+ async function openSource(result, params, signal, resolvedPath) {
138
138
  const cwd = getCwd();
139
139
  if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
140
140
  signal?.throwIfAborted();
141
- const opened = await readFile(path.resolve(cwd, result.path), { ...params, about: undefined }, result.line);
141
+ const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path);
142
142
  const block = opened.content[0];
143
143
  if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
144
144
  const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
@@ -185,12 +185,49 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
185
185
  }
186
186
  }));
187
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) });
188
+ 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) });
189
+ response.isError = params._independent !== true && results.some(r => r.error);
190
+ return response;
189
191
  }
190
192
  return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
191
193
  }
192
194
 
195
+ async function resolveSessionResource(uri, signal) {
196
+ const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
197
+ if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
198
+ const kind = match[1].toLowerCase();
199
+ const id = decodeURIComponent(match[2]);
200
+ 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");
201
+ const dir = hooks.artifactsDir?.();
202
+ if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
203
+ signal?.throwIfAborted();
204
+ const root = await fs.realpath(dir);
205
+ let file = id + ".md";
206
+ if (kind === "artifact") {
207
+ const matches = [];
208
+ let count = 0;
209
+ for await (const entry of await fs.opendir(root)) {
210
+ signal?.throwIfAborted();
211
+ if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
212
+ if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
213
+ }
214
+ if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
215
+ file = matches[0];
216
+ }
217
+ const target = await fs.realpath(path.join(root, file));
218
+ if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
219
+ if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
220
+ signal?.throwIfAborted();
221
+ return target;
222
+ }
223
+
193
224
  async function readSingle(params, cwd, targetParam, signal) {
225
+ if (isString(targetParam) && /^(?:agent|artifact):\/\//i.test(targetParam)) {
226
+ const target = await resolveSessionResource(targetParam, signal);
227
+ return params.resolve
228
+ ? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
229
+ : readFile(target, params, undefined, targetParam);
230
+ }
194
231
  if (isString(params?.query)) {
195
232
  const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
196
233
  return sourceRead(params.query, scope, signal, params);
@@ -208,9 +245,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
208
245
  }
209
246
 
210
247
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
211
- async function readFile(targetPath, params, sourceLine) {
248
+ async function readFile(targetPath, params, sourceLine, displayPath) {
212
249
  const cwd = getCwd();
213
- const rel = relativeSlash(cwd, targetPath);
250
+ const rel = displayPath ?? relativeSlash(cwd, targetPath);
214
251
  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
252
  if (mime) {
216
253
  if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
@@ -233,6 +270,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
233
270
  const offset = params?.offset ?? (sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1);
234
271
  const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
235
272
  const sliced = sliceLines(text, offset, params?.limit);
273
+ if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
274
+ throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use edit() for replacements or reconstruct resolve:true source windows`);
275
+ }
236
276
  if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
237
277
  let cap = budget - 160;
238
278
  if (params.resolve) {
@@ -397,11 +437,16 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
397
437
  const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
398
438
  if (signal?.aborted) throw new Error("aborted");
399
439
  if (!isString(params?.content)) throw new Error("write requires string content");
400
- const content = params.content;
440
+ if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
441
+ let content = params.content;
442
+ if (params.allowReadArtifacts !== true && /\[read truncated;|…\[(?:host-result|output|value) truncated \d+ chars\]…/u.test(content)) {
443
+ 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");
444
+ }
401
445
  let prevText = "";
402
446
  try {
403
447
  prevText = await vfs.read(target, { preserveRead: true });
404
448
  } catch (error) { if (error.code !== "ENOENT") throw error; }
449
+ if (params.append === true) content = prevText + content;
405
450
  const { speculative } = await vfs.write(target, content);
406
451
  index.touch(relativeSlash(cwd, target));
407
452
  const diff = buildWriteDiff(target, prevText, content);
@@ -527,6 +572,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
527
572
  vfs.invalidateCache();
528
573
  index.invalidate();
529
574
  clearPathCache();
575
+ hooks.workspaceChanged();
530
576
  }
531
577
  const { stdout, stderr } = res;
532
578
  let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
@@ -596,7 +642,10 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
596
642
  export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
597
643
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
598
644
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
599
- const vfs = new CausalVfs(() => index.invalidate(), target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
645
+ const vfs = new CausalVfs(paths => {
646
+ index.invalidate();
647
+ notifyWorkspaceChanged(paths);
648
+ }, target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
600
649
  const executors = registry?.executors ?? new Map();
601
650
  const definitions = registry?.definitions ?? new Map();
602
651
  const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
@@ -611,6 +660,18 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
611
660
  let trace = [];
612
661
  let callListener = null;
613
662
  const scheduler = createNativeScheduler();
663
+ // Advisory host event, not a tool or a transaction participant. Consumers
664
+ // invalidate synchronously; failures must never affect committed bytes.
665
+ function notifyWorkspaceChanged(paths = null) {
666
+ if (!isFunction(pi?.events?.emit)) return;
667
+ const event = Object.freeze({
668
+ version: 1, cwd: path.resolve(getCwd()),
669
+ paths: paths === null ? null : Object.freeze([...new Set(paths)]),
670
+ });
671
+ try { pi.events.emit("workspace:changed", event)?.catch?.(() => {}); } catch {}
672
+ }
673
+ hooks.workspaceChanged = notifyWorkspaceChanged;
674
+ hooks.artifactsDir = () => activeCtx?.sessionManager?.getArtifactsDir?.();
614
675
  hooks.commandEnv = () => {
615
676
  const env = { ...process.env };
616
677
  const current = {
@@ -800,6 +861,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
800
861
  const delegated = hostTool(name);
801
862
  const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
802
863
  if (exec) {
864
+ if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
803
865
  const fallbackDiff = await writeFallbackDiff(name, args);
804
866
  const mutating = isMutatingTool(name, config, args, definitions.get(name));
805
867
  if (mutating) await vfs.prepareExternalMutation(name);
@@ -812,7 +874,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
812
874
  completeRecord(record, res, fallbackDiff);
813
875
  return res;
814
876
  } finally {
815
- if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); }
877
+ if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); notifyWorkspaceChanged(); }
816
878
  }
817
879
  }
818
880
 
@@ -54,6 +54,26 @@ function expandedBlock(span, raw, opts) {
54
54
  return out.join("\n");
55
55
  }
56
56
 
57
+ function focusedText(raw, lower, stems, relPath, opts) {
58
+ const hits = lower.map((line, i) => ({i, score: stems.filter(s => line.includes(s)).length}))
59
+ .filter(hit => hit.score > 0).sort((a, b) => b.score - a.score || a.i - b.i);
60
+ const parts = [];
61
+ const covered = new Set();
62
+ let budget = opts.maxChars;
63
+ for (const {i} of hits) {
64
+ if (parts.length >= opts.maxExpanded) break;
65
+ if (covered.has(i)) continue;
66
+ const start = Math.max(0, i - 3), end = Math.min(raw.length, i + 4);
67
+ const block = raw.slice(start, end).map((line, j) => String(start + j + 1).padStart(5) + " " + line).join("\n");
68
+ if (block.length > budget) continue;
69
+ parts.push(block); budget -= block.length;
70
+ for (let j = start; j < end; j++) covered.add(j);
71
+ }
72
+ const status = parts.length ? "focused text windows (not a complete file)"
73
+ : hits.length ? "matching text exceeds view budget; first match at line " + (hits[0].i + 1) : "no matching text";
74
+ return {text: "// " + relPath + " · " + status + "; read(path, line, count) for raw source\n" + parts.join("\n---\n"), expanded: parts.length, declarations: 0};
75
+ }
76
+
57
77
  /**
58
78
  * @param entry index entry (text + cached lines/surface)
59
79
  * @param about question or symbol; empty ⇒ pure skeleton (every body folded)
@@ -63,8 +83,8 @@ export function outlineFile(entry, relPath, about, options = {}) {
63
83
  const { raw, lower } = WorkspaceIndex.linesOf(entry);
64
84
  const lineCount = raw.length;
65
85
  const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
66
- if (spans.length === 0) return null; // no structure: caller falls back to plain text
67
86
  const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
87
+ if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
68
88
  const expanded = chooseExpanded(spans, lower, stems, raw, opts);
69
89
 
70
90
  const parts = [];
@@ -102,7 +102,7 @@ export class WorkspaceIndex {
102
102
  let ok = false;
103
103
  try {
104
104
  let timer = null;
105
- const watcher = fs.watch(root, { recursive: true }, () => {
105
+ const watcher = fs.watch(root, { recursive: true, persistent: false }, () => {
106
106
  if (timer) return;
107
107
  timer = setTimeout(() => {
108
108
  timer = null;
package/src/fs/diff.js CHANGED
@@ -74,10 +74,11 @@ export function buildPatchDiff(filePath, patchText) {
74
74
  let inHunk = false;
75
75
 
76
76
  for (const patchLine of patchLines) {
77
- const headerMatch = (/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)/).exec(patchLine);
77
+ const headerMatch = (/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?/).exec(patchLine);
78
78
  if (headerMatch) {
79
- oldLineNum = Number(headerMatch[1]);
80
- newLineNum = Number(headerMatch[2]);
79
+ // A zero-length range names the line before the insertion/deletion point.
80
+ oldLineNum = Number(headerMatch[1]) + Number(headerMatch[2] === "0");
81
+ newLineNum = Number(headerMatch[3]) + Number(headerMatch[4] === "0");
81
82
  inHunk = true;
82
83
  continue;
83
84
  }
@@ -86,7 +87,7 @@ export function buildPatchDiff(filePath, patchText) {
86
87
  if (!kind) continue;
87
88
  if (kind === "remove") {
88
89
  removed += 1;
89
- lines.push({ type: "remove", lineNum: oldLineNum, text: patchLine.slice(1) });
90
+ lines.push({ type: "remove", lineNum: oldLineNum, newLineNum, text: patchLine.slice(1) });
90
91
  oldLineNum += 1;
91
92
  } else if (kind === "add") {
92
93
  added += 1;
package/src/fs/vfs.js CHANGED
@@ -157,7 +157,7 @@ export class CausalVfs {
157
157
  this.setCache(entry.logicalPath, entry.content);
158
158
  this.expected.delete(entry.logicalPath);
159
159
  }
160
- if (staged.length) this.onNewFile?.();
160
+ if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
161
161
  } catch (error) {
162
162
  failed = true;
163
163
  const recoveryErrors = [];
@@ -173,6 +173,7 @@ export class CausalVfs {
173
173
  }
174
174
  }
175
175
  this.invalidateCache();
176
+ if (recoveryErrors.length) this.onNewFile?.(null);
176
177
  if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
177
178
  throw error;
178
179
  } finally {
@@ -60,6 +60,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
60
60
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
61
61
  throw new Error(`${opName} requires path`);
62
62
  }
63
+ if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
63
64
  const resolvedCwd = getResolvedCwd(cwd);
64
65
  const target = path.resolve(resolvedCwd, inputPath.trim());
65
66
  assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
@@ -49,6 +49,12 @@ function unwrapValue(res) {
49
49
  return res;
50
50
  }
51
51
 
52
+ function unwrapRead(res, args) {
53
+ const value = unwrapValue(res);
54
+ if (args.complete === true && res?.truncated) throw new Error("incomplete read: complete:true refuses truncated host output");
55
+ return value;
56
+ }
57
+
52
58
  function unwrapJsonValue(res) {
53
59
  const value = unwrapValue(res);
54
60
  try {
@@ -124,8 +130,8 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
124
130
  const wave = pending.slice(start, start + 64);
125
131
  const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
126
132
  const run = wave.length === 1
127
- ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapValue(res)], errors: [] }))
128
- : nova.call("read", args).then(res => { unwrapValue(res); return { values: res.items, errors: res.itemErrors ?? [] }; });
133
+ ? nova.call("read", wave[0].args).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
134
+ : nova.call("read", args).then(res => { unwrapRead(res, args); return { values: res.items, errors: res.itemErrors ?? [] }; });
129
135
  void run.then(({ values, errors }) => {
130
136
  if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
131
137
  for (let i = 0; i < wave.length; i++) {
@@ -143,6 +149,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
143
149
  const read = async (p, a, b) => {
144
150
  assertScope();
145
151
  const args = readArgs(p, a, b);
152
+ if (args.complete === true && (args.outline || args.evidence || args.about)) throw new Error("complete:true requires a raw file read, not an outline or evidence view");
146
153
  const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
147
154
  p = args.path;
148
155
  if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
@@ -150,18 +157,21 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
150
157
  if (Array.isArray(p)) {
151
158
  if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
152
159
  if (p.some(item => !isString(item) || !item.trim())) throw new Error("read paths must be non-empty strings");
153
- const readEach = () => Promise.all(p.map(async item => {
154
- try { return await read({ ...args, path: item }); }
155
- catch (error) { return `[read error: ${item}] ${error.message}`; }
156
- }));
160
+ const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
157
161
  if (!batchRead || args.resolve) return readEach();
158
162
  const res = await invoke("read", args);
159
- unwrapValue(res);
163
+ const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
164
+ if (failed >= 0) throw new Error(`read failed for ${p[failed]}: ${res.itemErrors[failed]}; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes`);
165
+ unwrapRead(res, args);
160
166
  if (Array.isArray(res?.items)) return res.items;
161
167
  // Captured host executor without batch support: fan out.
162
168
  return readEach();
163
169
  }
164
- if (!batchRead) return args.resolve ? unwrapJsonValue(await invoke("read", args)) : unwrapValue(await invoke("read", args));
170
+ if (!batchRead) {
171
+ const res = await invoke("read", args);
172
+ unwrapRead(res, args);
173
+ return args.resolve ? unwrapJsonValue(res) : unwrapRead(res, args);
174
+ }
165
175
  const key = JSON.stringify({ ...args, path: undefined });
166
176
  if (queuedReads.length && queuedReads[0].key !== key) flushReads();
167
177
  return new Promise((resolve, reject) => {
@@ -139,7 +139,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
139
139
  const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
140
140
  let logTruncated = false;
141
141
  if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
142
- if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters");
142
+ if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters; split large writes into write({path,content,append:true}) chunks");
143
143
  if (signal?.aborted) return fail(ABORT_MESSAGE);
144
144
  const runId = ++runSeq;
145
145
  const timeoutMs = config.timeoutMs ?? 60000;