pi-hashline-edit-pro 4.2.6 → 4.2.7

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/src/read.ts CHANGED
@@ -17,7 +17,7 @@ import { abortIf, makePrepareArguments, numberedRead, visLines, splitLines } fro
17
17
  import { loadP, loadGuide } from "./prompts";
18
18
  import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
19
19
  import { valAccess } from "./validation";
20
- import { markServed as markServedScoped } from "./anchor-registry";
20
+ import { markServed as markServedScoped, withAnchorSession } from "./anchor-registry";
21
21
  import { buildServedMap } from "./served";
22
22
  import { Text } from "@earendil-works/pi-tui";
23
23
  const R_DESC = loadP("../prompts/read.md");
@@ -204,62 +204,64 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
204
204
  },
205
205
 
206
206
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
207
- const rawPath = params.path;
208
- const absolutePath = toCwd(rawPath, ctx.cwd);
207
+ return withAnchorSession(ctx, async () => {
208
+ const rawPath = params.path;
209
+ const absolutePath = toCwd(rawPath, ctx.cwd);
209
210
 
210
- abortIf(signal);
211
- await valAccess(absolutePath, rawPath);
211
+ abortIf(signal);
212
+ await valAccess(absolutePath, rawPath);
212
213
 
213
- abortIf(signal);
214
- const file = await loadFileKindAndText(absolutePath, { maxLines: MAX_HASH_LINES, displayPath: rawPath });
215
- if (file.kind === "image") {
216
- const builtinRead = createReadTool(ctx.cwd);
217
- const executeBuiltinRead = builtinRead.execute as unknown as (
218
- toolCallId: string,
219
- input: typeof params,
220
- abortSignal: typeof signal,
221
- onUpdate: typeof _onUpdate,
222
- context: typeof ctx,
223
- ) => ReturnType<typeof builtinRead.execute>;
224
- return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
225
- }
226
- const { normalized, fileHashes, hadUtf8DecodeErrors, absolutePath: resolvedPath } = await readNormFile(
227
- rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
228
- );
229
- const fileLines = splitLines(normalized);
230
- const preview = await fmtReadPreview(
231
- normalized,
232
- {
233
- offset: params.offset,
234
- limit: params.limit,
235
- },
236
- fileHashes,
237
- resolvedPath,
238
- );
239
- markServedScoped(resolvedPath, buildServedMap(fileHashes, fileLines, preview.servedHashes), new Set(fileHashes));
240
- const snapshotId = await safeSnapId(absolutePath, "read");
241
- const previewText =
242
- hadUtf8DecodeErrors
243
- ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
244
- : preview.text;
214
+ abortIf(signal);
215
+ const file = await loadFileKindAndText(absolutePath, { maxLines: MAX_HASH_LINES, displayPath: rawPath });
216
+ if (file.kind === "image") {
217
+ const builtinRead = createReadTool(ctx.cwd);
218
+ const executeBuiltinRead = builtinRead.execute as unknown as (
219
+ toolCallId: string,
220
+ input: typeof params,
221
+ abortSignal: typeof signal,
222
+ onUpdate: typeof _onUpdate,
223
+ context: typeof ctx,
224
+ ) => ReturnType<typeof builtinRead.execute>;
225
+ return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
226
+ }
227
+ const { normalized, fileHashes, hadUtf8DecodeErrors, absolutePath: resolvedPath } = await readNormFile(
228
+ rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
229
+ );
230
+ const fileLines = splitLines(normalized);
231
+ const preview = await fmtReadPreview(
232
+ normalized,
233
+ {
234
+ offset: params.offset,
235
+ limit: params.limit,
236
+ },
237
+ fileHashes,
238
+ resolvedPath,
239
+ );
240
+ markServedScoped(resolvedPath, buildServedMap(fileHashes, fileLines, preview.servedHashes), new Set(fileHashes));
241
+ const snapshotId = await safeSnapId(absolutePath, "read");
242
+ const previewText =
243
+ hadUtf8DecodeErrors
244
+ ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
245
+ : preview.text;
245
246
 
246
- return {
247
- content: [{ type: "text", text: previewText }],
248
- details: {
249
- truncation: preview.truncation,
250
- snapshotId,
251
- offset: params.offset ?? 1,
252
- ...(preview.nextOffset !== undefined
253
- ? { nextOffset: preview.nextOffset }
254
- : {}),
255
- metrics: {
256
- truncated: !!preview.truncation,
247
+ return {
248
+ content: [{ type: "text", text: previewText }],
249
+ details: {
250
+ truncation: preview.truncation,
251
+ snapshotId,
252
+ offset: params.offset ?? 1,
257
253
  ...(preview.nextOffset !== undefined
258
- ? { next_offset: preview.nextOffset }
254
+ ? { nextOffset: preview.nextOffset }
259
255
  : {}),
256
+ metrics: {
257
+ truncated: !!preview.truncation,
258
+ ...(preview.nextOffset !== undefined
259
+ ? { next_offset: preview.nextOffset }
260
+ : {}),
261
+ },
260
262
  },
261
- },
262
- };
263
+ };
264
+ });
263
265
  },
264
266
  });
265
267
  }
@@ -332,6 +332,10 @@ function genSpanDiff(
332
332
  return { diff: output.join("\n"), firstChangedLine, lineNumbers };
333
333
  }
334
334
 
335
+ function overDiffInputLimit(oldContent: string, newContent: string): boolean {
336
+ return Buffer.byteLength(oldContent, "utf-8") + Buffer.byteLength(newContent, "utf-8") > MAX_DIFF_INPUT_BYTES;
337
+ }
338
+
335
339
  export function genDiff(
336
340
  oldContent: string,
337
341
  newContent: string,
@@ -350,7 +354,7 @@ export function genDiff(
350
354
  return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers };
351
355
  }
352
356
  }
353
- if (!limits?.unlimited && Buffer.byteLength(oldContent, "utf-8") + Buffer.byteLength(newContent, "utf-8") > MAX_DIFF_INPUT_BYTES) {
357
+ if (!limits?.unlimited && overDiffInputLimit(oldContent, newContent)) {
354
358
  const guardedRange = changedRange(oldContent, newContent);
355
359
  const guardNote = `[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`;
356
360
  if (!guardedRange || !newContentHashes) {
@@ -580,6 +584,9 @@ export function genPatch(
580
584
  const patchOpts: Record<string, unknown> = { context: 4 };
581
585
  const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
582
586
  if (ho !== undefined) patchOpts.headerOptions = ho;
587
+ if (!limits?.unlimited && overDiffInputLimit(oldContent, newContent)) {
588
+ return { patch: "", truncated: true };
589
+ }
583
590
  const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
584
591
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
585
592
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
@@ -7,7 +7,7 @@ import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, t
7
7
  import { servedHashesFromDiff, buildServedMap } from "./served";
8
8
  import { contentChecksum } from "./hashline/hasher";
9
9
  import { hashSource } from "./hashline";
10
- import { markServed as markServedScoped, freeAnchors, adoptAnchors } from "./anchor-registry";
10
+ import { markServed as markServedScoped, freeAnchors, adoptAnchors, withAnchorSession } from "./anchor-registry";
11
11
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
12
12
  import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
13
13
  import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
@@ -114,52 +114,20 @@ export function regUndo(pi: ExtensionAPI): void {
114
114
  return renderEditResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
115
115
  },
116
116
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
117
- const path = params.path;
118
- if (typeof path !== "string" || path.length === 0) {
119
- throw new Error('[E_BAD_SHAPE] Undo request requires a non-empty "path" string.');
120
- }
121
- const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
122
-
123
- const undo = await getUndo(mutationTargetPath);
124
- if (!undo) {
125
- return {
126
- content: [
127
- {
128
- type: "text",
129
- text: `No undo history for ${path}.`,
130
- },
131
- ],
132
- isError: true,
133
- details: {},
134
- };
135
- }
136
-
137
- return withFileMutationQueue(mutationTargetPath, async () => {
138
- let currentRaw: string | undefined;
139
- let currentIdentity: FileIdentity | undefined;
140
- try {
141
- const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
142
- const handle = await open(mutationTargetPath, constants.O_RDONLY | noFollow);
143
- try {
144
- const { dev, ino } = await handle.stat();
145
- currentIdentity = { dev, ino };
146
- currentRaw = await handle.readFile("utf-8");
147
- } finally {
148
- await handle.close();
149
- }
150
- } catch (error) {
151
- if (errCode(error) !== "ENOENT") throw error;
117
+ return withAnchorSession(ctx, async () => {
118
+ const path = params.path;
119
+ if (typeof path !== "string" || path.length === 0) {
120
+ throw new Error('[E_BAD_SHAPE] Undo request requires a non-empty "path" string.');
152
121
  }
122
+ const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
153
123
 
154
- if (
155
- currentRaw !== undefined &&
156
- currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)
157
- ) {
124
+ const undo = await getUndo(mutationTargetPath);
125
+ if (!undo) {
158
126
  return {
159
127
  content: [
160
128
  {
161
129
  type: "text",
162
- text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted. The current content already contains your applied edit plus that external change and is most likely the correct state. Do not modify the file to make an undo possible and do not revert your own edit. The undo record is kept. Call read() to verify the current state, then stop.`
130
+ text: `No undo history for ${path}.`,
163
131
  },
164
132
  ],
165
133
  isError: true,
@@ -167,82 +135,116 @@ export function regUndo(pi: ExtensionAPI): void {
167
135
  };
168
136
  }
169
137
 
170
- await writeAtomic(
171
- mutationTargetPath,
172
- undo.bom + restoreEndings(undo.content, undo.originalEnding),
173
- currentIdentity,
174
- );
138
+ return withFileMutationQueue(mutationTargetPath, async () => {
139
+ let currentRaw: string | undefined;
140
+ let currentIdentity: FileIdentity | undefined;
141
+ try {
142
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
143
+ const handle = await open(mutationTargetPath, constants.O_RDONLY | noFollow);
144
+ try {
145
+ const { dev, ino } = await handle.stat();
146
+ currentIdentity = { dev, ino };
147
+ currentRaw = await handle.readFile("utf-8");
148
+ } finally {
149
+ await handle.close();
150
+ }
151
+ } catch (error) {
152
+ if (errCode(error) !== "ENOENT") throw error;
153
+ }
175
154
 
176
- const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
177
- const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
178
- const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes, { unlimited: true });
179
- const linesAddedByReplace = cntDiff(diffResult.diff, "+");
180
- const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
181
- const restoredRange = changedRange(currentNormalized, undo.content);
182
- const undoSpans = spansFromHashes(currentHashes, undo.hashes);
183
- const undoDiffResult = genDiff(currentNormalized, undo.content, await getDiffContextLines(), undo.hashes, currentHashes, undefined, undoSpans);
184
- const undoDiff = undoDiffResult.diff;
155
+ if (
156
+ currentRaw !== undefined &&
157
+ currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)
158
+ ) {
159
+ return {
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted. The current content already contains your applied edit plus that external change and is most likely the correct state. Do not modify the file to make an undo possible and do not revert your own edit. The undo record is kept. Call read() to verify the current state, then stop.`
164
+ },
165
+ ],
166
+ isError: true,
167
+ details: {},
168
+ };
169
+ }
185
170
 
186
- try {
187
- const store = await loadHashStore();
188
- const undoLines = splitLines(undo.content);
189
- persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes, undoLines.map((line) => contentChecksum(hashSource(line))));
190
- freeAnchors(mutationTargetPath);
191
- adoptAnchors(
171
+ await writeAtomic(
192
172
  mutationTargetPath,
193
- new Map(undoLines.map((line, i) => [undo.hashes[i]!, contentChecksum(hashSource(line))])),
173
+ undo.bom + restoreEndings(undo.content, undo.originalEnding),
174
+ currentIdentity,
194
175
  );
195
- markServedScoped(
196
- mutationTargetPath,
197
- buildServedMap(undo.hashes, undoLines, servedHashesFromDiff(undoDiff)),
198
- new Set(undo.hashes),
199
- );
200
- } catch (error) {
201
- console.error("Failed to restore hash store snapshot after undo:", error);
202
- }
203
176
 
204
- await clearUndo(mutationTargetPath);
177
+ const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
178
+ const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
179
+ const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes, { unlimited: true });
180
+ const linesAddedByReplace = cntDiff(diffResult.diff, "+");
181
+ const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
182
+ const restoredRange = changedRange(currentNormalized, undo.content);
183
+ const undoSpans = spansFromHashes(currentHashes, undo.hashes);
184
+ const undoDiffResult = genDiff(currentNormalized, undo.content, await getDiffContextLines(), undo.hashes, currentHashes, undefined, undoSpans);
185
+ const undoDiff = undoDiffResult.diff;
205
186
 
206
- const parts: string[] = [
207
- `Undone last change on ${path}.`,
208
- ];
209
- if (currentRaw === undefined) {
210
- parts.push("The file was deleted; restored it from undo history.");
211
- }
212
- if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
187
+ try {
188
+ const store = await loadHashStore();
189
+ const undoLines = splitLines(undo.content);
190
+ persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes, undoLines.map((line) => contentChecksum(hashSource(line))));
191
+ freeAnchors(mutationTargetPath);
192
+ adoptAnchors(
193
+ mutationTargetPath,
194
+ new Map(undoLines.map((line, i) => [undo.hashes[i]!, contentChecksum(hashSource(line))])),
195
+ );
196
+ markServedScoped(
197
+ mutationTargetPath,
198
+ buildServedMap(undo.hashes, undoLines, servedHashesFromDiff(undoDiff)),
199
+ new Set(undo.hashes),
200
+ );
201
+ } catch (error) {
202
+ console.error("Failed to restore hash store snapshot after undo:", error);
203
+ }
204
+
205
+ await clearUndo(mutationTargetPath);
206
+
207
+ const parts: string[] = [
208
+ `Undone last change on ${path}.`,
209
+ ];
210
+ if (currentRaw === undefined) {
211
+ parts.push("The file was deleted; restored it from undo history.");
212
+ }
213
+ if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
214
+ parts.push(
215
+ `Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
216
+ );
217
+ }
213
218
  parts.push(
214
- `Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
219
+ "Call read for fresh anchors.",
215
220
  );
216
- }
217
- parts.push(
218
- "Call read for fresh anchors.",
219
- );
220
221
 
221
- const patchResult = genPatch(path, currentNormalized, undo.content);
222
- return {
223
- content: [
224
- {
225
- type: "text",
226
- text: parts.join("\n"),
222
+ const patchResult = genPatch(path, currentNormalized, undo.content);
223
+ return {
224
+ content: [
225
+ {
226
+ type: "text",
227
+ text: parts.join("\n"),
228
+ },
229
+ ],
230
+ details: {
231
+ diff: undoDiff,
232
+ diffLineNumbers: undoDiffResult.lineNumbers,
233
+ patch: patchResult.patch,
234
+ ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
235
+ metrics: buildMetrics({
236
+ classification: "applied",
237
+ editsAttempted: 1,
238
+ noopEditsCount: 0,
239
+ warningsCount: 0,
240
+ firstChangedLine: restoredRange?.firstChangedLine,
241
+ lastChangedLine: restoredRange?.lastChangedLine,
242
+ addedLines: linesRemovedByReplace,
243
+ removedLines: linesAddedByReplace,
244
+ }),
227
245
  },
228
- ],
229
- details: {
230
- diff: undoDiff,
231
- diffLineNumbers: undoDiffResult.lineNumbers,
232
- patch: patchResult.patch,
233
- ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
234
- metrics: buildMetrics({
235
- classification: "applied",
236
- editsAttempted: 1,
237
- noopEditsCount: 0,
238
- warningsCount: 0,
239
- firstChangedLine: restoredRange?.firstChangedLine,
240
- lastChangedLine: restoredRange?.lastChangedLine,
241
- addedLines: linesRemovedByReplace,
242
- removedLines: linesAddedByReplace,
243
- }),
244
- },
245
- };
246
+ };
247
+ });
246
248
  });
247
249
  },
248
250
  });
package/src/replace.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  type RRState,
30
30
  } from "./replace-render";
31
31
  import { loadHashStore, type HashStore } from "./hash-store";
32
- import { adoptAnchors, servedForPath } from "./anchor-registry";
32
+ import { adoptAnchors, servedForPath, withAnchorSession } from "./anchor-registry";
33
33
  import { resolveTarget } from "./fs-write";
34
34
  import { toCwd } from "./paths";
35
35
  import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
@@ -275,78 +275,80 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
275
275
  renderCall: editRenderCallWrapper(compPreview),
276
276
  renderResult: editRenderResultWrapper,
277
277
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
278
- const canonical = normReq(params);
279
- assertReq(canonical);
280
- const normalizedParams = canonical;
281
- const targetPath = await resolveEditTargetWithRequirement({
282
- removeFrom: normalizedParams.remove_from,
283
- removeTo: normalizedParams.remove_to,
284
- providedPath: normalizedParams.path,
285
- cwd: ctx.cwd,
286
- }).catch((error: unknown) => {
287
- const member = batchMemberFor(_toolCallId);
288
- if (member) noteBatchFailure(member, error);
289
- else suffixPoisonCause(_toolCallId, error);
290
- throw error;
291
- });
292
- return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
293
- const dedupMode = await getBoundaryDedupMode();
294
- const dedupOn = dedupMode !== "off";
295
- const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
296
- const boundaryBypass = dedupOn ? consumeBoundaryBypass(mutationTargetPath, noopPayload) : false;
297
- const strictBoundaryDedup = dedupMode === "strict" && !boundaryBypass;
298
- const member = batchMemberFor(_toolCallId);
299
- if (!member) {
278
+ return withAnchorSession(ctx, async () => {
279
+ const canonical = normReq(params);
280
+ assertReq(canonical);
281
+ const normalizedParams = canonical;
282
+ const targetPath = await resolveEditTargetWithRequirement({
283
+ removeFrom: normalizedParams.remove_from,
284
+ removeTo: normalizedParams.remove_to,
285
+ providedPath: normalizedParams.path,
286
+ cwd: ctx.cwd,
287
+ }).catch((error: unknown) => {
288
+ const member = batchMemberFor(_toolCallId);
289
+ if (member) noteBatchFailure(member, error);
290
+ else suffixPoisonCause(_toolCallId, error);
291
+ throw error;
292
+ });
293
+ return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
294
+ const dedupMode = await getBoundaryDedupMode();
295
+ const dedupOn = dedupMode !== "off";
296
+ const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
297
+ const boundaryBypass = dedupOn ? consumeBoundaryBypass(mutationTargetPath, noopPayload) : false;
298
+ const strictBoundaryDedup = dedupMode === "strict" && !boundaryBypass;
299
+ const member = batchMemberFor(_toolCallId);
300
+ if (!member) {
301
+ try {
302
+ const pipe = await execPipeline(
303
+ targetPath,
304
+ normalizedParams,
305
+ ctx.cwd,
306
+ { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
307
+ );
308
+ const appliedWarnings = boundaryBypass
309
+ ? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
310
+ : [];
311
+ return await commitEdit(pipe, {
312
+ path: pipe.path,
313
+ absolutePath,
314
+ mutationTargetPath,
315
+ editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
316
+ signal,
317
+ appliedWarnings,
318
+ onApplied: () => { if (dedupOn) clearBoundaryBypass(mutationTargetPath); },
319
+ onNoopDedup: dedupOn ? () => markBoundaryNoop(mutationTargetPath, noopPayload) : undefined,
320
+ });
321
+ } catch (error) {
322
+ const detail = error instanceof Error ? error.message : String(error);
323
+ if (boundaryBypass && !detail.includes("File was written;")) markBoundaryNoop(mutationTargetPath, noopPayload);
324
+ throw error;
325
+ }
326
+ }
327
+ let built: { edit: HEdit; warnings: string[] };
300
328
  try {
301
- const pipe = await execPipeline(
302
- targetPath,
303
- normalizedParams,
304
- ctx.cwd,
305
- { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
306
- );
307
- const appliedWarnings = boundaryBypass
308
- ? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
309
- : [];
310
- return await commitEdit(pipe, {
311
- path: pipe.path,
312
- absolutePath,
313
- mutationTargetPath,
314
- editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
315
- signal,
316
- appliedWarnings,
317
- onApplied: () => { if (dedupOn) clearBoundaryBypass(mutationTargetPath); },
318
- onNoopDedup: dedupOn ? () => markBoundaryNoop(mutationTargetPath, noopPayload) : undefined,
319
- });
329
+ built = buildReplaceHEdit(normalizedParams);
320
330
  } catch (error) {
321
- const detail = error instanceof Error ? error.message : String(error);
322
- if (boundaryBypass && !detail.includes("File was written;")) markBoundaryNoop(mutationTargetPath, noopPayload);
331
+ noteBatchFailure(member, error);
332
+ if (boundaryBypass) markBoundaryNoop(mutationTargetPath, noopPayload);
323
333
  throw error;
324
334
  }
325
- }
326
- let built: { edit: HEdit; warnings: string[] };
327
- try {
328
- built = buildReplaceHEdit(normalizedParams);
329
- } catch (error) {
330
- noteBatchFailure(member, error);
331
- if (boundaryBypass) markBoundaryNoop(mutationTargetPath, noopPayload);
332
- throw error;
333
- }
334
- const appliedWarnings = boundaryBypass
335
- ? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
336
- : [];
337
- return executeBatchMember({
338
- kind: "replace",
339
- member,
340
- targetPath,
341
- mutationTargetPath,
342
- cwd: ctx.cwd,
343
- signal,
344
- hedit: built.edit,
345
- extraWarnings: [...built.warnings, ...appliedWarnings],
346
- skipBoundaryDedup: boundaryBypass,
347
- strictBoundaryDedup,
348
- noopPayload,
349
- bypassConsumed: boundaryBypass,
335
+ const appliedWarnings = boundaryBypass
336
+ ? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
337
+ : [];
338
+ return executeBatchMember({
339
+ kind: "replace",
340
+ member,
341
+ targetPath,
342
+ mutationTargetPath,
343
+ cwd: ctx.cwd,
344
+ signal,
345
+ hedit: built.edit,
346
+ extraWarnings: [...built.warnings, ...appliedWarnings],
347
+ skipBoundaryDedup: boundaryBypass,
348
+ strictBoundaryDedup,
349
+ noopPayload,
350
+ bypassConsumed: boundaryBypass,
351
+ });
350
352
  });
351
353
  });
352
354
  },
package/src/utils.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { NUL_CONTENT_MSG } from "./constants";
2
+
1
3
  export function isRec(value: unknown): value is Record<string, unknown> {
2
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
3
5
  }
@@ -74,6 +76,10 @@ export function abortIf(signal?: AbortSignal): void {
74
76
  if (signal?.aborted) throw new Error("Operation aborted");
75
77
  }
76
78
 
79
+ export function assertNoNul(lines: string[]): void {
80
+ if (lines.some((line) => line.includes("\0"))) throw new Error(NUL_CONTENT_MSG);
81
+ }
82
+
77
83
  export function errCode(error: unknown): string | undefined {
78
84
  if (error instanceof Error) {
79
85
  return (error as NodeJS.ErrnoException).code;
package/src/write-hook.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
3
  import { HASH_SEP } from "./hashline/hash";
4
- import { servedForPath } from "./anchor-registry";
4
+ import { servedForPath, withAnchorSession } from "./anchor-registry";
5
5
  import { resolveInCwd } from "./fs-write";
6
6
  import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
7
7
 
@@ -35,7 +35,7 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
35
35
  }
36
36
 
37
37
  export function registerWriteHook(pi: ExtensionAPI): void {
38
- pi.on("tool_call", async (event, ctx) => {
38
+ pi.on("tool_call", async (event, ctx) => withAnchorSession(ctx, async () => {
39
39
  if (event.toolName !== "write") return;
40
40
  const input = event.input as Record<string, unknown> | undefined;
41
41
  if (!input || !isRec(input)) return;
@@ -53,5 +53,5 @@ export function registerWriteHook(pi: ExtensionAPI): void {
53
53
  console.error("write hook failed:", error);
54
54
  }
55
55
  return;
56
- });
56
+ }));
57
57
  }