@oh-my-pi/pi-coding-agent 14.5.1 → 14.5.3

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.
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  *
3
3
  * Flat locator + verb edit mode backed by hashline anchors. Each entry carries
4
- * one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`).
4
+ * one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`, `sed`).
5
5
  * The runtime resolves those verbs into internal anchor-scoped edits and still
6
6
  * reuses hashline's staleness scheme (`computeLineHash`) verbatim.
7
7
  *
8
8
  * External shapes (one entry):
9
- * { path, loc: "5th", splice: ["..."] }
10
- * { path, loc: "5th", pre: ["..."] }
11
- * { path, loc: "5th", post: ["..."] }
12
- * { path, loc: "5th", pre: [...], splice: [...], post: [...] }
13
- * { path, loc: "$", pre: [...] } // prepend to file
14
- * { path, loc: "$", post: [...] } // append to file
15
- * { path, loc: "$", sed: "s/foo/bar/" } // sed on every line
9
+ * { path, loc: "5th", splice: ["..."] } // line replace
10
+ * { path, loc: "(5th)", splice: ["..."] } // block body replace
11
+ * { path, loc: "[5th]", splice: ["..."] } // whole node replace
12
+ * { path, loc: "[5th", splice: ["..."] } // anchor (incl) closer-1
13
+ * { path, loc: "5th]", splice: ["..."] } // opener+1 anchor (incl)
14
+ * { path, loc: "5th", pre: [...], splice: [...], post: [...] } // line verbs combinable
15
+ * { path, loc: "$", pre: [...] | post: [...] | sed: {...} } // file-scoped
16
16
  *
17
- * `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
18
- * a blank line. Line ranges are not supported.
19
- * in the same entry.
17
+ * `splice: []` deletes; `splice: [""]` replaces with a single blank line. These
18
+ * apply uniformly to single-line and bracketed (region) locators.
19
+ *
20
+ * Bracket forms in `loc` are reserved for `splice` (region replacement). `pre`,
21
+ * `post`, and `sed` reject bracketed locators — they are line-only.
20
22
  *
21
23
  * For deleting or moving files, the agent should use bash.
22
24
  */
@@ -29,7 +31,9 @@ import { assertEditableFileContent } from "../../tools/auto-generated-guard";
29
31
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
30
32
  import { outputMeta } from "../../tools/output-meta";
31
33
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
34
+ import { checkBodyBraceBalance, type DelimiterKind, findEnclosingBlock } from "../block";
32
35
  import { generateDiffString } from "../diff";
36
+ import { applyIndent, detectIndentStyle, stripCommonIndent } from "../indent";
33
37
  import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
34
38
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
35
39
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
@@ -59,17 +63,23 @@ const textSchema = Type.Array(Type.String());
59
63
  export const atomEditSchema = Type.Object(
60
64
  {
61
65
  loc: Type.String({
62
- description: 'edit location: "1ab", "$", or path override like "a.ts:1ab"',
66
+ description: "edit location",
63
67
  examples: ["1ab", "$", "src/foo.ts:1ab"],
64
68
  }),
65
69
  splice: Type.Optional(textSchema),
66
70
  pre: Type.Optional(textSchema),
67
71
  post: Type.Optional(textSchema),
68
72
  sed: Type.Optional(
69
- Type.String({
70
- description: "sed-style substitution applied to the anchored line",
71
- examples: ["s/foo/bar/", "s|api|API|g", "s/<pat>/<rep>/F"],
72
- }),
73
+ Type.Object(
74
+ {
75
+ pat: Type.String({ description: "regex" }),
76
+ rep: Type.String({ description: "expression to replace with" }),
77
+ g: Type.Optional(Type.Boolean({ description: "global flag", default: false })),
78
+ },
79
+ {
80
+ additionalProperties: false,
81
+ },
82
+ ),
73
83
  ),
74
84
  },
75
85
  { additionalProperties: false },
@@ -98,14 +108,41 @@ export type AtomEdit =
98
108
  | { op: "append_file"; lines: string[] }
99
109
  | { op: "prepend_file"; lines: string[] }
100
110
  | { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
101
- | { op: "sed_file"; spec: SedSpec; expression: string };
111
+ | { op: "sed_file"; spec: SedSpec; expression: string }
112
+ | { op: "splice_block"; pos: Anchor; spec: SpliceBlockSpec; bracket: BracketShape };
102
113
 
103
114
  export interface SedSpec {
104
115
  pattern: string;
105
116
  replacement: string;
106
117
  global: boolean;
107
- ignoreCase: boolean;
108
- literal: boolean;
118
+ }
119
+
120
+ export interface SpliceBlockSpec {
121
+ body: string[];
122
+ kind: DelimiterKind;
123
+ }
124
+
125
+ type BracketShape = "none" | "body" | "node" | "left_incl" | "left_excl" | "right_incl" | "right_excl";
126
+
127
+ // File-extension lookup for the block delimiter family used when `loc`
128
+ // has bracket forms. Most languages are brace-family; lisp-family uses `(`.
129
+ // Anything not listed defaults to `{` (covers the long tail of brace-style
130
+ // languages without enumerating every extension).
131
+ const LISP_EXTENSIONS = new Set(["clj", "cljs", "cljc", "edn", "lisp", "lsp", "el", "scm", "ss", "rkt", "fnl"]);
132
+
133
+ function fileExtension(path: string | undefined): string | undefined {
134
+ if (!path) return undefined;
135
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
136
+ const base = slash >= 0 ? path.slice(slash + 1) : path;
137
+ const dot = base.lastIndexOf(".");
138
+ if (dot <= 0) return undefined;
139
+ return base.slice(dot + 1).toLowerCase();
140
+ }
141
+
142
+ function resolveBlockDelimiterForPath(path: string | undefined): DelimiterKind {
143
+ const ext = fileExtension(path);
144
+ if (ext && LISP_EXTENSIONS.has(ext)) return "(";
145
+ return "{";
109
146
  }
110
147
 
111
148
  // ═══════════════════════════════════════════════════════════════════════════
@@ -129,7 +166,9 @@ const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC
129
166
  // `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
130
167
  // fails the anchor pattern.
131
168
  const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
132
- const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
169
+ const PATH_LOC_SPLIT_RE = new RegExp(
170
+ `^(.+?):([\\[(]?${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?[\\])]?)$`,
171
+ );
133
172
 
134
173
  function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
135
174
  let next: Record<string, unknown> | undefined;
@@ -142,7 +181,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
142
181
  return (next ?? fields) as AtomToolEdit;
143
182
  }
144
183
 
145
- type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
184
+ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor; bracket: BracketShape } | { kind: "file" };
146
185
 
147
186
  // ═══════════════════════════════════════════════════════════════════════════
148
187
  // Resolution
@@ -221,28 +260,52 @@ export function resolveAtomEntryPaths(
221
260
  }
222
261
 
223
262
  function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
224
- if (raw === "$") return { kind: "file" };
263
+ const trimmed = raw.trim();
264
+ if (trimmed === "$") return { kind: "file" };
265
+
266
+ const leading = trimmed[0];
267
+ const trailing = trimmed[trimmed.length - 1];
268
+ const hasLeading = leading === "[" || leading === "(";
269
+ const hasTrailing = trailing === "]" || trailing === ")";
270
+ if ((leading === "(" && trailing === "]") || (leading === "[" && trailing === ")")) {
271
+ throw new Error(
272
+ `Edit ${editIndex}: mixed bracket inclusivity in loc is ambiguous; use [anchor, (anchor, anchor], anchor), [anchor], or a bare anchor.`,
273
+ );
274
+ }
275
+
276
+ let inner = trimmed;
277
+ if (hasLeading) inner = inner.slice(1);
278
+ if (hasTrailing) inner = inner.slice(0, -1);
279
+
225
280
  // Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
226
281
  // loc (e.g. line content like `i--`) should not trigger the range error.
227
- const dash = raw.indexOf("-");
282
+ const dash = inner.indexOf("-");
228
283
  if (dash > 0) {
229
- const left = raw.slice(0, dash);
230
- const right = raw.slice(dash + 1);
284
+ const left = inner.slice(0, dash);
285
+ const right = inner.slice(dash + 1);
231
286
  if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
232
287
  throw new Error(
233
288
  `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
234
289
  );
235
290
  }
236
291
  }
237
- const pos = parseAnchor(raw, "loc");
292
+ const pos = parseAnchor(inner, "loc");
238
293
  // Capture an optional content suffix after the anchor: `82zu| for (...)`.
239
294
  // The suffix acts as a hint for anchor disambiguation when the model's hash
240
295
  // is wrong but the content reveals the intended line.
241
- const hint = extractAnchorContentHint(raw);
296
+ const hint = extractAnchorContentHint(inner);
242
297
  if (hint !== undefined) {
243
298
  pos.contentHint = hint;
244
299
  }
245
- return { kind: "anchor", pos };
300
+
301
+ let bracket: BracketShape = "none";
302
+ if (leading === "[" && trailing === "]") bracket = "node";
303
+ else if (leading === "[") bracket = "left_incl";
304
+ else if (leading === "(" && trailing === ")") bracket = "body";
305
+ else if (leading === "(") bracket = "left_excl";
306
+ else if (trailing === "]") bracket = "right_incl";
307
+ else if (trailing === ")") bracket = "right_excl";
308
+ return { kind: "anchor", pos, bracket };
246
309
  }
247
310
 
248
311
  function extractAnchorContentHint(raw: string): string | undefined {
@@ -258,70 +321,43 @@ function extractAnchorContentHint(raw: string): string | undefined {
258
321
  return hint;
259
322
  }
260
323
 
261
- function parseSedExpression(raw: string, editIndex: number): SedSpec {
262
- if (typeof raw !== "string" || raw.length < 3) {
263
- throw new Error(
264
- `Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
265
- );
324
+ function parseSedSpec(input: unknown, editIndex: number): SedSpec {
325
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
326
+ throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g?}.`);
266
327
  }
267
- // Tolerate a missing leading `s`: models occasionally emit `/foo/bar/` directly.
268
- // As long as the first character is a valid delimiter, treat the expression as
269
- // if `s` was prepended.
270
- let bodyStart = 0;
271
- if (raw[0] === "s") {
272
- bodyStart = 1;
328
+ const obj = input as Record<string, unknown>;
329
+ const pat = obj.pat;
330
+ const rep = obj.rep;
331
+ if (typeof pat !== "string" || pat.length === 0) {
332
+ throw new Error(`Edit ${editIndex}: sed.pat must be a non-empty string.`);
273
333
  }
274
- const delim = raw[bodyStart]!;
275
- if (/[\sA-Za-z0-9\\]/.test(delim)) {
334
+ if (pat.includes("\n")) {
276
335
  throw new Error(
277
- `Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
336
+ `Edit ${editIndex}: sed.pat must be a single line; contains a newline. Use \`splice\` to replace multiple lines, anchoring the first changed line and listing replacement lines in the array.`,
278
337
  );
279
338
  }
280
- const parts: [string, string] = ["", ""];
281
- let bucket: 0 | 1 = 0;
282
- let i = bodyStart + 1;
283
- while (i < raw.length) {
284
- const c = raw[i]!;
285
- if (c === "\\" && raw[i + 1] === delim) {
286
- parts[bucket] += delim;
287
- i += 2;
288
- continue;
289
- }
290
- if (c === delim) {
291
- if (bucket === 0) {
292
- bucket = 1;
293
- i += 1;
294
- continue;
295
- }
296
- i += 1;
297
- break;
298
- }
299
- parts[bucket] += c;
300
- i += 1;
301
- }
302
- if (bucket !== 1) {
303
- throw new Error(
304
- `Edit ${editIndex}: malformed sed expression ${JSON.stringify(raw)}. Expected three ${JSON.stringify(delim)} separators.`,
305
- );
339
+ if (typeof rep !== "string") {
340
+ throw new Error(`Edit ${editIndex}: sed.rep must be a string.`);
306
341
  }
307
- const flagsStr = raw.slice(i);
342
+ const rawGlobal = obj.g;
308
343
  let global = false;
309
- let ignoreCase = false;
310
- let literal = false;
311
- for (const f of flagsStr) {
312
- if (f === "g") global = true;
313
- else if (f === "i") ignoreCase = true;
314
- else if (f === "F") literal = true;
315
- else {
316
- throw new Error(
317
- `Edit ${editIndex}: unknown sed flag ${JSON.stringify(f)}. Supported flags: g (all), i (case-insensitive), F (literal).`,
318
- );
344
+ if (rawGlobal !== undefined) {
345
+ if (typeof rawGlobal !== "boolean") {
346
+ throw new Error(`Edit ${editIndex}: sed.g must be a boolean when provided.`);
319
347
  }
348
+ global = rawGlobal;
320
349
  }
321
- if (parts[0] === "") {
322
- throw new Error(`Edit ${editIndex}: sed expression has empty pattern.`);
323
- }
324
- return { pattern: parts[0], replacement: parts[1], global, ignoreCase, literal };
350
+ return { pattern: pat, replacement: rep, global };
351
+ }
352
+
353
+ function formatSedExpression(spec: SedSpec): string {
354
+ const obj: { pat: string; rep: string; g?: boolean } = {
355
+ pat: spec.pattern,
356
+ rep: spec.replacement,
357
+ };
358
+ // Only emit non-default flags so error messages stay compact (g defaults false).
359
+ if (spec.global) obj.g = true;
360
+ return JSON.stringify(obj);
325
361
  }
326
362
 
327
363
  function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
@@ -340,12 +376,8 @@ function applySedToLine(
340
376
  currentLine: string,
341
377
  spec: SedSpec,
342
378
  ): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
343
- if (spec.literal) {
344
- return applyLiteralSed(currentLine, spec);
345
- }
346
379
  let flags = "";
347
380
  if (spec.global) flags += "g";
348
- if (spec.ignoreCase) flags += "i";
349
381
  let re: RegExp | undefined;
350
382
  let compileError: string | undefined;
351
383
  try {
@@ -357,18 +389,13 @@ function applySedToLine(
357
389
  re.lastIndex = 0;
358
390
  const probe = re.exec(currentLine);
359
391
  re.lastIndex = 0;
360
- if (probe && probe[0].length === 0) {
361
- // Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace`
362
- // to insert the replacement at the match position rather than substitute,
363
- // which is almost never what models intend. Reject with a pointer to the
364
- // dedicated insertion verbs.
365
- return {
366
- result: currentLine,
367
- matched: false,
368
- error: `pattern ${JSON.stringify(spec.pattern)} matches an empty string; use \`pre\`/\`post\`/\`splice\` to insert or replace whole lines, or use a non-empty pattern`,
369
- };
370
- }
371
- return { result: currentLine.replace(re, spec.replacement), matched: true };
392
+ // Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace` to
393
+ // insert the replacement at the match position rather than substitute. When that
394
+ // happens, fall through to the literal-substring fallback below the model almost
395
+ // always meant the pattern literally (`()` is the parens, `^` is a caret, etc.).
396
+ if (!probe || probe[0].length > 0) {
397
+ return { result: currentLine.replace(re, spec.replacement), matched: true };
398
+ }
372
399
  }
373
400
  // Fall back to literal substring match. Models frequently send sed patterns
374
401
  // containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
@@ -391,7 +418,7 @@ function classifyAtomEdit(edit: AtomToolEdit): string {
391
418
  return verbs.length > 0 ? verbs.join("+") : "unknown";
392
419
  }
393
420
 
394
- function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
421
+ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0, path?: string): AtomEdit[] {
395
422
  const entry = stripNullAtomFields(edit);
396
423
  const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
397
424
  if (verbKeysPresent.length === 0) {
@@ -417,12 +444,31 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
417
444
  resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
418
445
  }
419
446
  if (entry.sed !== undefined) {
420
- const spec = parseSedExpression(entry.sed, editIndex);
421
- resolved.push({ op: "sed_file", spec, expression: entry.sed });
447
+ const spec = parseSedSpec(entry.sed, editIndex);
448
+ resolved.push({ op: "sed_file", spec, expression: formatSedExpression(spec) });
422
449
  }
423
450
  return resolved;
424
451
  }
425
452
 
453
+ if (loc.bracket !== "none") {
454
+ // Bracketed locator: only `splice` is meaningful (region replacement).
455
+ const hasInvalidVerb = entry.pre !== undefined || entry.post !== undefined || entry.sed !== undefined;
456
+ if (hasInvalidVerb) {
457
+ throw new Error(
458
+ `Edit ${editIndex}: bracket forms in loc are splice-only; remove pre/post/sed or use a bare anchor.`,
459
+ );
460
+ }
461
+ if (entry.splice === undefined) {
462
+ throw new Error(
463
+ `Edit ${editIndex}: bracket loc requires \`splice\`. Bare anchors are line-only; brackets address a region.`,
464
+ );
465
+ }
466
+ const kind = resolveBlockDelimiterForPath(path);
467
+ const body = hashlineParseText(entry.splice);
468
+ resolved.push({ op: "splice_block", pos: loc.pos, spec: { body, kind }, bracket: loc.bracket });
469
+ return resolved;
470
+ }
471
+
426
472
  if (entry.pre !== undefined) {
427
473
  resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
428
474
  }
@@ -448,8 +494,8 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
448
494
  // matching `sed`. The explicit replacement wins; the redundant `sed` would
449
495
  // otherwise trigger a confusing `Conflicting ops` rejection.
450
496
  if (!spliceIsExplicitReplacement) {
451
- const spec = parseSedExpression(entry.sed, editIndex);
452
- resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
497
+ const spec = parseSedSpec(entry.sed, editIndex);
498
+ resolved.push({ op: "sed", pos: loc.pos, spec, expression: formatSedExpression(spec) });
453
499
  }
454
500
  }
455
501
  return resolved;
@@ -466,6 +512,7 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
466
512
  case "post":
467
513
  case "del":
468
514
  case "sed":
515
+ case "splice_block":
469
516
  yield edit.pos;
470
517
  return;
471
518
  default:
@@ -537,16 +584,18 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
537
584
  }
538
585
 
539
586
  function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
540
- // For each anchor line, at most one mutating op (splice/del).
541
- // `pre`/`post` (insert ops) may coexist with them they don't mutate the anchor line.
587
+ // For each anchor line, at most one mutating op (splice/del). Multiple `sed`
588
+ // ops on the same line are allowed and applied sequentially. `pre`/`post`
589
+ // (insert ops) may coexist with them — they don't mutate the anchor line.
542
590
  const mutatingPerLine = new Map<number, string>();
543
591
  for (const edit of edits) {
544
592
  if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
545
593
  const existing = mutatingPerLine.get(edit.pos.line);
546
594
  if (existing) {
595
+ if (existing === "sed" && edit.op === "sed") continue;
547
596
  throw new Error(
548
597
  `Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
549
- `At most one of splice/del/sed is allowed per anchor.`,
598
+ `At most one of splice/del is allowed per anchor.`,
550
599
  );
551
600
  }
552
601
  mutatingPerLine.set(edit.pos.line, edit.op);
@@ -564,6 +613,151 @@ export interface AtomNoopEdit {
564
613
  current: string;
565
614
  }
566
615
 
616
+ interface SpliceBlockApplyResult {
617
+ text: string;
618
+ firstChangedLine: number | undefined;
619
+ }
620
+
621
+ type SpliceBlockEdit = Extract<AtomEdit, { op: "splice_block" }>;
622
+
623
+ function lineStartOffset(text: string, line: number): number {
624
+ let currentLine = 1;
625
+ let offset = 0;
626
+ while (offset < text.length && currentLine < line) {
627
+ if (text[offset] === "\n") currentLine++;
628
+ offset++;
629
+ }
630
+ return offset;
631
+ }
632
+
633
+ function lineEndOffset(text: string, line: number): number {
634
+ let offset = lineStartOffset(text, line);
635
+ while (offset < text.length && text[offset] !== "\n") offset++;
636
+ return offset;
637
+ }
638
+
639
+ function lineEndIncludingNewlineOffset(text: string, line: number): number {
640
+ const offset = lineEndOffset(text, line);
641
+ return text[offset] === "\n" ? offset + 1 : offset;
642
+ }
643
+
644
+ function spliceBlockLocatorLabel(bracket: BracketShape): string {
645
+ switch (bracket) {
646
+ case "none":
647
+ case "body":
648
+ return "(anchor)";
649
+ case "node":
650
+ return "[anchor]";
651
+ case "left_incl":
652
+ return "[anchor";
653
+ case "left_excl":
654
+ return "(anchor";
655
+ case "right_incl":
656
+ return "anchor]";
657
+ case "right_excl":
658
+ return "anchor)";
659
+ }
660
+ }
661
+
662
+ function applySpliceBlockEdits(
663
+ originalText: string,
664
+ edits: SpliceBlockEdit[],
665
+ warnings: string[],
666
+ ): SpliceBlockApplyResult {
667
+ // Sort by anchor line descending so applying earlier ops doesn't shift
668
+ // later anchors. (Multiple splice_block ops within one call are assumed
669
+ // non-overlapping; overlapping ranges are not supported.)
670
+ const sorted = [...edits].sort((a, b) => b.pos.line - a.pos.line);
671
+ const destStyle = detectIndentStyle(originalText);
672
+ let text = originalText;
673
+ let firstChangedLine: number | undefined;
674
+
675
+ for (const edit of sorted) {
676
+ const kind: DelimiterKind = edit.spec.kind;
677
+ const found = findEnclosingBlock(text, edit.pos.line, { kind, depth: 0 });
678
+ if ("message" in found) {
679
+ throw new Error(`splice at anchor ${edit.pos.line}: ${found.message}`);
680
+ }
681
+ const replacedLineCount = found.closeLine - found.openLine + 1;
682
+ warnings.push(
683
+ `splice locator ${spliceBlockLocatorLabel(edit.bracket)} replaced \`${kind}\` block at lines ${found.openLine}-${found.closeLine} ` +
684
+ `(${replacedLineCount} lines, 1 of ${found.enclosingCount} enclosing \`${kind}\` blocks).`,
685
+ );
686
+ const balanceErr = checkBodyBraceBalance(edit.spec.body.join("\n"), kind);
687
+ if (balanceErr) {
688
+ throw new Error(`splice at anchor ${edit.pos.line}: ${balanceErr}`);
689
+ }
690
+
691
+ const stripped = stripCommonIndent(edit.spec.body);
692
+ const bodyPrefix = found.bodyLineIndent ?? `${found.openerLineIndent}${defaultIndentUnit(destStyle)}`;
693
+
694
+ let replacementText: string;
695
+ let replaceStart: number;
696
+ let replaceEnd: number;
697
+
698
+ switch (edit.bracket) {
699
+ case "node": {
700
+ const indented = applyIndent(stripped, found.openerLineIndent, destStyle);
701
+ replacementText = indented.join("\n");
702
+ replaceStart = found.openLineStart;
703
+ replaceEnd = found.closeOffsetExclusive;
704
+ break;
705
+ }
706
+ case "left_incl":
707
+ case "left_excl": {
708
+ const indented = applyIndent(stripped, bodyPrefix, destStyle);
709
+ replacementText = `${indented.join("\n")}\n${found.openerLineIndent}`;
710
+ replaceStart =
711
+ edit.bracket === "left_incl"
712
+ ? lineStartOffset(text, edit.pos.line)
713
+ : lineEndIncludingNewlineOffset(text, edit.pos.line);
714
+ replaceEnd = found.bodyEnd;
715
+ break;
716
+ }
717
+ case "right_incl":
718
+ case "right_excl": {
719
+ const indented = applyIndent(stripped, bodyPrefix, destStyle);
720
+ replacementText = `\n${indented.join("\n")}\n`;
721
+ replaceStart = found.bodyStart;
722
+ replaceEnd =
723
+ edit.bracket === "right_incl"
724
+ ? lineEndIncludingNewlineOffset(text, edit.pos.line)
725
+ : lineStartOffset(text, edit.pos.line);
726
+ break;
727
+ }
728
+ case "none":
729
+ case "body": {
730
+ const goInline = found.sameLine && stripped.length === 1;
731
+ if (goInline) {
732
+ const single = stripped.length === 0 ? "" : stripped[0]!.trim();
733
+ const pad = kind === "{" ? " " : "";
734
+ replacementText = single.length > 0 ? `${pad}${single}${pad}` : pad;
735
+ } else {
736
+ const indented = applyIndent(stripped, bodyPrefix, destStyle);
737
+ replacementText = `\n${indented.join("\n")}\n${found.openerLineIndent}`;
738
+ }
739
+ replaceStart = found.bodyStart;
740
+ replaceEnd = found.bodyEnd;
741
+ break;
742
+ }
743
+ }
744
+
745
+ const before = text.slice(0, replaceStart);
746
+ const after = text.slice(replaceEnd);
747
+ const newText = before + replacementText + after;
748
+
749
+ text = newText;
750
+ if (firstChangedLine === undefined || found.openLine < firstChangedLine) {
751
+ firstChangedLine = found.openLine;
752
+ }
753
+ }
754
+ return { text, firstChangedLine };
755
+ }
756
+
757
+ function defaultIndentUnit(style: { kind: "tab" | "space"; width: number }): string {
758
+ return style.kind === "tab" ? "\t" : " ".repeat(Math.max(1, style.width));
759
+ }
760
+
567
761
  export function applyAtomEdits(
568
762
  text: string,
569
763
  edits: AtomEdit[],
@@ -586,7 +780,39 @@ export function applyAtomEdits(
586
780
  if (mismatches.length > 0) {
587
781
  throw new HashlineMismatchError(mismatches, fileLines);
588
782
  }
589
- validateNoConflictingAnchorOps(edits);
783
+ // When a `del` and a `sed`/`splice` target the same anchor (across separate edit
784
+ // entries), the `del` is almost always a hallucinated cleanup the model added on top
785
+ // of the real replacement. Drop the `del` silently so the replacement wins, matching
786
+ // the in-entry handling for `splice: []` paired with `sed`.
787
+ const replacedLines = new Set<number>();
788
+ for (const e of edits) {
789
+ if (e.op === "splice" || e.op === "sed") replacedLines.add(e.pos.line);
790
+ }
791
+ let effective = edits;
792
+ if (replacedLines.size > 0) {
793
+ effective = edits.filter(e => !(e.op === "del" && replacedLines.has(e.pos.line)));
794
+ }
795
+ validateNoConflictingAnchorOps(effective);
796
+
797
+ // splice_block ops own their entire block range. To keep line numbers sane,
798
+ // they cannot mix with other anchor-scoped ops in the same call. They may
799
+ // coexist with each other (sorted by openLine descending so earlier ops
800
+ // don't shift later anchors).
801
+ const spliceBlockEdits = effective.filter(
802
+ (e): e is Extract<AtomEdit, { op: "splice_block" }> => e.op === "splice_block",
803
+ );
804
+ if (spliceBlockEdits.length > 0) {
805
+ const result = applySpliceBlockEdits(text, spliceBlockEdits, warnings);
806
+ if (result.firstChangedLine !== undefined) {
807
+ if (firstChangedLine === undefined || result.firstChangedLine < firstChangedLine) {
808
+ firstChangedLine = result.firstChangedLine;
809
+ }
810
+ }
811
+ // Continue pipeline against the post-splice_block text.
812
+ fileLines.length = 0;
813
+ for (const line of result.text.split("\n")) fileLines.push(line);
814
+ effective = effective.filter(e => e.op !== "splice_block");
815
+ }
590
816
 
591
817
  const trackFirstChanged = (line: number) => {
592
818
  if (firstChangedLine === undefined || line < firstChangedLine) {
@@ -603,7 +829,7 @@ export function applyAtomEdits(
603
829
  const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
604
830
  const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
605
831
  const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
606
- edits.forEach((edit, idx) => {
832
+ effective.forEach((edit, idx) => {
607
833
  if (edit.op === "append_file") appendEdits.push({ edit, idx });
608
834
  else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
609
835
  else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
@@ -659,18 +885,19 @@ export function applyAtomEdits(
659
885
  anchorMutated = true;
660
886
  break;
661
887
  case "sed": {
662
- const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
888
+ const input = replacementSet ? (replacement[0] ?? "") : currentLine;
889
+ const { result, matched, error, literalFallback } = applySedToLine(input, edit.spec);
663
890
  if (error) {
664
891
  throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
665
892
  }
666
893
  if (!matched) {
667
894
  throw new Error(
668
- `Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(currentLine)}`,
895
+ `Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(input)}`,
669
896
  );
670
897
  }
671
898
  if (literalFallback) {
672
899
  warnings.push(
673
- `sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
900
+ `sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Escape regex metacharacters in the pattern to match as a regex.`,
674
901
  );
675
902
  }
676
903
  replacement = [result];
@@ -762,7 +989,7 @@ export function applyAtomEdits(
762
989
  anyMatched = true;
763
990
  if (r.literalFallback && !warnedLiteralFallback) {
764
991
  warnings.push(
765
- `sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
992
+ `sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Escape regex metacharacters in the pattern to match as a regex.`,
766
993
  );
767
994
  warnedLiteralFallback = true;
768
995
  }
@@ -806,7 +1033,7 @@ export async function executeAtomSingle(
806
1033
  ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
807
1034
  const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
808
1035
 
809
- const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i));
1036
+ const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i, path));
810
1037
 
811
1038
  enforcePlanModeWrite(session, path, { op: "update" });
812
1039