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/README.md +5 -3
- package/index.ts +18 -9
- package/package.json +1 -1
- package/src/anchor-registry.ts +262 -36
- package/src/constants.ts +3 -0
- package/src/grep.ts +154 -152
- package/src/hashline/resolve.ts +2 -1
- package/src/insert.ts +77 -74
- package/src/payload-contract.ts +3 -1
- package/src/read.ts +53 -51
- package/src/replace-diff.ts +8 -1
- package/src/replace-undo.ts +109 -107
- package/src/replace.ts +71 -69
- package/src/utils.ts +6 -0
- package/src/write-hook.ts +3 -3
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
|
-
|
|
208
|
-
|
|
207
|
+
return withAnchorSession(ctx, async () => {
|
|
208
|
+
const rawPath = params.path;
|
|
209
|
+
const absolutePath = toCwd(rawPath, ctx.cwd);
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
abortIf(signal);
|
|
212
|
+
await valAccess(absolutePath, rawPath);
|
|
212
213
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
? {
|
|
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
|
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -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 &&
|
|
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);
|
package/src/replace-undo.ts
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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: `
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
219
|
+
"Call read for fresh anchors.",
|
|
215
220
|
);
|
|
216
|
-
}
|
|
217
|
-
parts.push(
|
|
218
|
-
"Call read for fresh anchors.",
|
|
219
|
-
);
|
|
220
221
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
if (boundaryBypass
|
|
331
|
+
noteBatchFailure(member, error);
|
|
332
|
+
if (boundaryBypass) markBoundaryNoop(mutationTargetPath, noopPayload);
|
|
323
333
|
throw error;
|
|
324
334
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
}
|