@oh-my-pi/pi-coding-agent 14.5.7 → 14.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +7 -7
  3. package/src/config/model-registry.ts +23 -1
  4. package/src/config/settings-schema.ts +23 -0
  5. package/src/edit/modes/atom.lark +7 -5
  6. package/src/edit/modes/atom.ts +462 -56
  7. package/src/edit/modes/hashline.ts +21 -1
  8. package/src/lsp/index.ts +2 -4
  9. package/src/lsp/render.ts +0 -3
  10. package/src/lsp/types.ts +1 -4
  11. package/src/lsp/utils.ts +18 -14
  12. package/src/modes/components/settings-defs.ts +10 -0
  13. package/src/modes/controllers/command-controller.ts +17 -0
  14. package/src/modes/controllers/event-controller.ts +14 -9
  15. package/src/modes/controllers/input-controller.ts +13 -1
  16. package/src/modes/interactive-mode.ts +44 -23
  17. package/src/modes/types.ts +5 -2
  18. package/src/modes/utils/context-usage.ts +294 -0
  19. package/src/prompts/tools/atom.md +99 -44
  20. package/src/prompts/tools/exit-plan-mode.md +5 -39
  21. package/src/prompts/tools/lsp.md +2 -3
  22. package/src/prompts/tools/recipe.md +16 -0
  23. package/src/prompts/tools/task.md +34 -147
  24. package/src/prompts/tools/todo-write.md +22 -64
  25. package/src/session/compaction/compaction.ts +35 -22
  26. package/src/session/session-dump-format.ts +1 -0
  27. package/src/slash-commands/builtin-registry.ts +12 -5
  28. package/src/tools/bash.ts +149 -115
  29. package/src/tools/debug.ts +57 -70
  30. package/src/tools/index.ts +11 -0
  31. package/src/tools/recipe/index.ts +80 -0
  32. package/src/tools/recipe/render.ts +19 -0
  33. package/src/tools/recipe/runner.ts +219 -0
  34. package/src/tools/recipe/runners/cargo.ts +131 -0
  35. package/src/tools/recipe/runners/index.ts +8 -0
  36. package/src/tools/recipe/runners/just.ts +73 -0
  37. package/src/tools/recipe/runners/make.ts +101 -0
  38. package/src/tools/recipe/runners/pkg.ts +165 -0
  39. package/src/tools/recipe/runners/task.ts +72 -0
  40. package/src/tools/renderers.ts +2 -0
@@ -7,9 +7,11 @@
7
7
  * @Lid move cursor to just after the anchored line
8
8
  * Lid=TEXT set the anchored line to TEXT and move cursor after it
9
9
  * -Lid delete the anchored line and move cursor to its slot
10
+ * LidA..LidB=TEXT replace a range; following \TEXT lines continue it
11
+ * \TEXT append TEXT to the active replacement (set or range)
10
12
  * +TEXT insert TEXT at the cursor
11
- * $ move cursor to beginning of file
12
- * ^ move cursor to end of file
13
+ * ^ move cursor to beginning of file
14
+ * $ move cursor to end of file
13
15
  */
14
16
 
15
17
  import * as fs from "node:fs/promises";
@@ -57,6 +59,11 @@ export type AtomParams = Static<typeof atomEditParamsSchema>;
57
59
  const LID_RE = /^([1-9]\d*)([a-z]{2})/;
58
60
  const LID_EXACT_RE = /^([1-9]\d*)([a-z]{2})$/;
59
61
 
62
+ // Sentinel hash used for interior line anchors synthesized from `-LidA..LidB`
63
+ // range deletes. validateAtomAnchors recognizes this and skips hash checking
64
+ // (only the start and end Lids' hashes are validated by the user).
65
+ const RANGE_INTERIOR_HASH = "**";
66
+
60
67
  interface ParsedAnchor {
61
68
  line: number;
62
69
  hash: string;
@@ -67,6 +74,7 @@ type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op:
67
74
  type AnchorStmt =
68
75
  | { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
69
76
  | { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
77
+ | { kind: "before_anchor"; anchor: ParsedAnchor; lineNum: number }
70
78
  | { kind: "bof"; lineNum: number }
71
79
  | { kind: "eof"; lineNum: number };
72
80
 
@@ -79,6 +87,7 @@ type InsertStmt = {
79
87
  type DiffishAddStmt = {
80
88
  kind: "diffish_add";
81
89
  anchor: ParsedAnchor;
90
+ separator: "=" | "|";
82
91
  text: string;
83
92
  lineNum: number;
84
93
  };
@@ -92,7 +101,11 @@ type DeleteWithOldStmt = {
92
101
 
93
102
  type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
94
103
 
95
- type AtomCursor = { kind: "bof" } | { kind: "eof" } | { kind: "anchor"; anchor: Anchor };
104
+ type AtomCursor =
105
+ | { kind: "bof" }
106
+ | { kind: "eof" }
107
+ | { kind: "anchor"; anchor: Anchor }
108
+ | { kind: "before_anchor"; anchor: Anchor };
96
109
 
97
110
  export type AtomEdit =
98
111
  | { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
@@ -119,11 +132,12 @@ interface IndexedAnchorEdit {
119
132
  }
120
133
 
121
134
  function cloneCursor(cursor: AtomCursor): AtomCursor {
122
- if (cursor.kind !== "anchor") return cursor;
123
- return { kind: "anchor", anchor: { ...cursor.anchor } };
135
+ if (cursor.kind === "anchor") return { kind: "anchor", anchor: { ...cursor.anchor } };
136
+ if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
137
+ return cursor;
124
138
  }
125
139
 
126
- function parseLidStmt(body: string, lineNum: number): AnchorStmt | null {
140
+ function parseLidStmt(body: string, lineNum: number): ParsedStmt[] | null {
127
141
  const m = LID_RE.exec(body);
128
142
  if (!m) return null;
129
143
 
@@ -131,22 +145,133 @@ function parseLidStmt(body: string, lineNum: number): AnchorStmt | null {
131
145
  const hash = m[2];
132
146
  const rest = body.slice(m[0].length);
133
147
  const anchor = { line: ln, hash };
148
+
149
+ // Range replace: `LidA..LidB=TEXT` deletes the inclusive range LidA..LidB
150
+ // and inserts TEXT in its place. Following insert statements append more
151
+ // replacement lines through the normal hunk reorder path. Legacy `|` is
152
+ // accepted as a set separator for parity with single-line `Lid|TEXT`.
153
+ // Bare `LidA..LidB` recovers the common missing-`-` typo for range delete.
154
+ if (rest.startsWith("..")) {
155
+ const m2 = LID_RE.exec(rest.slice(2));
156
+ if (m2) {
157
+ const endLn = Number.parseInt(m2[1], 10);
158
+ const endHash = m2[2];
159
+ const after = rest.slice(2 + m2[0].length);
160
+ const range = `${ln}${hash}..${endLn}${endHash}`;
161
+ if (endLn < ln) {
162
+ throw new Error(
163
+ `Diff line ${lineNum}: range \`${range}\` ends before it starts. Use \`LidA..LidB=TEXT\` with LidA's line number ≤ LidB's.`,
164
+ );
165
+ }
166
+ if (endLn === ln && endHash !== hash) {
167
+ throw new Error(
168
+ `Diff line ${lineNum}: range \`${range}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`${ln}${hash}=TEXT\` for a single-line replacement.`,
169
+ );
170
+ }
171
+
172
+ const stmts: ParsedStmt[] = [];
173
+ for (let l = ln; l <= endLn; l++) {
174
+ const h = l === ln ? hash : l === endLn ? endHash : RANGE_INTERIOR_HASH;
175
+ stmts.push({
176
+ kind: "anchor_op",
177
+ anchor: { line: l, hash: h },
178
+ op: { op: "delete" },
179
+ lineNum,
180
+ });
181
+ }
182
+
183
+ if (after.trim().length === 0) return stmts;
184
+
185
+ const replacement = /^[ \t]*([=|])(.*)$/.exec(after);
186
+ if (replacement) {
187
+ if (replacement[2].includes("\r")) {
188
+ throw new Error(`Diff line ${lineNum}: set value contains a carriage return; use a single-line value.`);
189
+ }
190
+ stmts.push({ kind: "insert", text: replacement[2], lineNum });
191
+ return stmts;
192
+ }
193
+ }
194
+ }
195
+
134
196
  if (rest.length === 0) {
135
- return { kind: "bare_anchor", anchor, lineNum };
197
+ return [{ kind: "bare_anchor", anchor, lineNum }];
136
198
  }
137
199
 
138
200
  const replacement = /^[ \t]*([=|])(.*)$/.exec(rest);
139
- if (!replacement) return null;
140
- return {
141
- kind: "anchor_op",
142
- anchor,
143
- op: { op: "set", text: replacement[2], allowOldNewRepair: replacement[1] === "|" },
144
- lineNum,
145
- };
201
+ if (replacement) {
202
+ return [
203
+ {
204
+ kind: "anchor_op",
205
+ anchor,
206
+ op: { op: "set", text: replacement[2], allowOldNewRepair: replacement[1] === "|" },
207
+ lineNum,
208
+ },
209
+ ];
210
+ }
211
+
212
+ // Compound shorthand: `Lid+TEXT` collapses cursor-after-Lid + insert TEXT.
213
+ // Models sometimes write a run like `103rd=A` / `103rd+B` / `103rd+C` to
214
+ // mean "set 103 to A, then insert B and C below it". Treat each `Lid+...`
215
+ // as an independent cursor-move + insert; this matches semantics of the
216
+ // canonical `@Lid` + `+TEXT` two-line form.
217
+ if (rest[0] === "+") {
218
+ return [
219
+ { kind: "bare_anchor", anchor, lineNum },
220
+ { kind: "insert", text: rest.slice(1), lineNum },
221
+ ];
222
+ }
223
+
224
+ return null;
146
225
  }
147
226
 
148
227
  function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
149
228
  const trimmedBody = body.trimStart();
229
+
230
+ // Range delete: `-LidA..LidB` deletes the contiguous range LidA..LidB inclusive.
231
+ const rangeRe = /^([1-9]\d*)([a-z]{2})\.\.([1-9]\d*)([a-z]{2})$/;
232
+ const rangeMatch = rangeRe.exec(trimmedBody);
233
+ if (rangeMatch) {
234
+ const startLine = Number.parseInt(rangeMatch[1], 10);
235
+ const startHash = rangeMatch[2];
236
+ const endLine = Number.parseInt(rangeMatch[3], 10);
237
+ const endHash = rangeMatch[4];
238
+ if (endLine < startLine) {
239
+ throw new Error(
240
+ `Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` ends before it starts. Use \`-LidA..LidB\` with LidA's line number ≤ LidB's.`,
241
+ );
242
+ }
243
+ if (endLine === startLine && endHash !== startHash) {
244
+ throw new Error(
245
+ `Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`-${startLine}${startHash}\` for a single-line delete.`,
246
+ );
247
+ }
248
+ const stmts: ParsedStmt[] = [];
249
+ for (let ln = startLine; ln <= endLine; ln++) {
250
+ const hash = ln === startLine ? startHash : ln === endLine ? endHash : RANGE_INTERIOR_HASH;
251
+ stmts.push({
252
+ kind: "anchor_op",
253
+ anchor: { line: ln, hash },
254
+ op: { op: "delete" },
255
+ lineNum,
256
+ });
257
+ }
258
+ return stmts;
259
+ }
260
+
261
+ // `-LidA..LidB|TEXT` and `-LidA..LidB=TEXT` are not valid: ranges have no
262
+ // `|` (delete-with-old) form. Models reach for these when trying to
263
+ // "delete the range and replace with TEXT" — point at the `LidA..LidB=TEXT`
264
+ // shorthand instead.
265
+ const rangeWithSuffix = /^([1-9]\d*)([a-z]{2})\.\.([1-9]\d*)([a-z]{2})[ \t]*[=|](.*)$/.exec(trimmedBody);
266
+ if (rangeWithSuffix) {
267
+ const lidA = `${rangeWithSuffix[1]}${rangeWithSuffix[2]}`;
268
+ const lidB = `${rangeWithSuffix[3]}${rangeWithSuffix[4]}`;
269
+ const text = rangeWithSuffix[5];
270
+ throw new Error(
271
+ `Diff line ${lineNum}: \`-${lidA}..${lidB}\` cannot have a \`|\`/\`=\` suffix. To delete the range, use \`-${lidA}..${lidB}\` alone. To replace the range with one new line, drop the leading \`-\` and use \`${lidA}..${lidB}=${text}\`.`,
272
+ );
273
+ }
274
+
150
275
  const exact = LID_EXACT_RE.exec(trimmedBody);
151
276
  if (exact) {
152
277
  const ln = Number.parseInt(exact[1], 10);
@@ -171,6 +296,17 @@ function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
171
296
  return null;
172
297
  }
173
298
 
299
+ function parseIndentedHashlineStmt(line: string, lineNum: number): ParsedStmt[] | null {
300
+ const trimmed = line.trimStart();
301
+ if (trimmed === line) return null;
302
+ const stmts = parseLidStmt(trimmed, lineNum);
303
+ if (!stmts) return null;
304
+ const safeHashlineEcho = stmts.every(
305
+ stmt => stmt.kind === "bare_anchor" || (stmt.kind === "anchor_op" && stmt.op.op === "set"),
306
+ );
307
+ return safeHashlineEcho ? stmts : null;
308
+ }
309
+
174
310
  function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
175
311
  const text = line.trimStart();
176
312
  const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
@@ -200,18 +336,29 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
200
336
  const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
201
337
  if (line.length === 0) return [];
202
338
 
339
+ // `# ...` comments are silently ignored. Models often add section headers
340
+ // or annotations like `# Test 1: replace enum`; treating these as literal
341
+ // inserts corrupts files, and the canonical syntax has no comment op.
342
+ if (line[0] === "#") return [];
343
+
344
+ const indentedHashline = parseIndentedHashlineStmt(line, lineNum);
345
+ if (indentedHashline) return indentedHashline;
346
+
203
347
  // `+TEXT` inserts at the cursor. Everything after `+` is content. A
204
348
  // `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
205
349
  // emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
206
350
  if (line[0] === "+") {
207
351
  const body = line.slice(1);
352
+ if (body.startsWith(RANGE_CONTINUATION_SENTINEL)) {
353
+ return [{ kind: "insert", text: body.slice(RANGE_CONTINUATION_SENTINEL.length), lineNum }];
354
+ }
208
355
  const m = LID_RE.exec(body);
209
356
  if (m) {
210
357
  const sep = body[m[0].length];
211
358
  if (sep === "=" || sep === "|") {
212
359
  const ln = Number.parseInt(m[1], 10);
213
360
  const text = body.slice(m[0].length + 1);
214
- return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, text, lineNum }];
361
+ return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, separator: sep, text, lineNum }];
215
362
  }
216
363
  }
217
364
 
@@ -237,8 +384,58 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
237
384
  }
238
385
 
239
386
  // Canonical file-scope locators.
240
- if (line === "$") return [{ kind: "bof", lineNum }];
241
- if (line === "^") return [{ kind: "eof", lineNum }];
387
+ if (line === "^") return [{ kind: "bof", lineNum }];
388
+ if (line === "$") return [{ kind: "eof", lineNum }];
389
+
390
+ // Compound shorthand: `^+TEXT` and `$+TEXT` collapse a file-scope cursor
391
+ // move and an insert onto one line. Models occasionally do this when
392
+ // creating files from scratch (`^+content`) instead of the canonical
393
+ // `^\n+content`. No legitimate op line starts with `^+` or `$+`, so the
394
+ // expansion is unambiguous.
395
+ if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "+") {
396
+ const cursor: ParsedStmt = line[0] === "^" ? { kind: "bof", lineNum } : { kind: "eof", lineNum };
397
+ return [cursor, { kind: "insert", text: line.slice(2), lineNum }];
398
+ }
399
+
400
+ // `^=TEXT` and `$=TEXT` are not valid: `^` and `$` are cursor moves only.
401
+ // Models reach for these when trying to "replace the last line" or
402
+ // "replace the first line"; emit a clear diagnostic instead of falling
403
+ // through to "unrecognized op".
404
+ if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "=") {
405
+ const where = line[0] === "^" ? "first" : "last";
406
+ const sym = line[0];
407
+ throw new Error(
408
+ `Diff line ${lineNum}: \`${sym}=TEXT\` is not a valid op. \`${sym}\` only moves the cursor (${sym === "^" ? "BOF" : "EOF"}); it cannot replace a line. To replace the ${where} line, use its Lid (e.g. \`5xx=TEXT\`). To insert at ${sym === "^" ? "BOF" : "EOF"}, use \`${sym}\` followed by \`+TEXT\` on the next line.`,
409
+ );
410
+ }
411
+
412
+ // `^Lid` cursor moves BEFORE the anchored line (insert above). Compound
413
+ // shorthand `^Lid+TEXT` collapses the cursor move and an insert into one
414
+ // line. `^Lid=TEXT` and `^Lid|TEXT` are flagged as ambiguous: pick either
415
+ // `^Lid` (cursor before) + `+TEXT`, or `Lid=TEXT` (replace in place).
416
+ if (line[0] === "^" && line.length > 1) {
417
+ const m = LID_RE.exec(line.slice(1));
418
+ if (m) {
419
+ const ln = Number.parseInt(m[1], 10);
420
+ const hash = m[2];
421
+ const sep = line[1 + m[0].length];
422
+ if (sep === undefined) {
423
+ return [{ kind: "before_anchor", anchor: { line: ln, hash }, lineNum }];
424
+ }
425
+ if (sep === "+") {
426
+ const text = line.slice(1 + m[0].length + 1);
427
+ return [
428
+ { kind: "before_anchor", anchor: { line: ln, hash }, lineNum },
429
+ { kind: "insert", text, lineNum },
430
+ ];
431
+ }
432
+ if (sep === "=" || sep === "|") {
433
+ throw new Error(
434
+ `Diff line ${lineNum}: \`^${ln}${hash}${sep}...\` mixes \`^Lid\` (cursor before line) with \`Lid=TEXT\` (replace line). Pick one: \`^${ln}${hash}\` then \`+TEXT\` on the next line to insert above; or \`${ln}${hash}=TEXT\` to replace the line in place.`,
435
+ );
436
+ }
437
+ }
438
+ }
242
439
 
243
440
  // `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
244
441
  // historical `-Lid TEXT` delete-then-insert recovery.
@@ -259,7 +456,7 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
259
456
  if (deleteStmt) return deleteStmt;
260
457
 
261
458
  const lidStmt = parseLidStmt(body, lineNum);
262
- if (lidStmt) return [lidStmt];
459
+ if (lidStmt) return lidStmt;
263
460
 
264
461
  throwMalformedLidDiagnostic(line, lineNum, raw);
265
462
  }
@@ -268,17 +465,17 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
268
465
  // `@Lid|TEXT`, `@$`, and `@^`.
269
466
  if (line[0] === "@") {
270
467
  const body = line.slice(1);
271
- if (body === "$") return [{ kind: "bof", lineNum }];
272
- if (body === "^") return [{ kind: "eof", lineNum }];
468
+ if (body === "^") return [{ kind: "bof", lineNum }];
469
+ if (body === "$") return [{ kind: "eof", lineNum }];
273
470
  const lidStmt = parseLidStmt(body, lineNum);
274
- if (lidStmt) return [lidStmt];
471
+ if (lidStmt) return lidStmt;
275
472
  throwMalformedLidDiagnostic(line, lineNum, raw);
276
473
  }
277
474
 
278
475
  // `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
279
476
  // A bare `Lid` is a cursor move.
280
477
  const lidStmt = parseLidStmt(line, lineNum);
281
- if (lidStmt) return [lidStmt];
478
+ if (lidStmt) return lidStmt;
282
479
 
283
480
  if (/^[a-z]{2}(?=[ \t]*[=|])/.test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
284
481
  throwMalformedLidDiagnostic(line, lineNum, raw);
@@ -288,14 +485,82 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
288
485
  // emitted multi-line content after a `Lid=` or similar without `+` prefixes,
289
486
  // or pasted raw context. Silently treating these as inserts corrupts files.
290
487
  const preview = line.length > 80 ? `${line.slice(0, 80)}…` : line;
488
+ const trailingDash = /^([1-9]\d*[a-z]{2})-\s*$/.exec(line);
489
+ if (trailingDash) {
490
+ throw new Error(
491
+ `Diff line ${lineNum}: \`${line}\` looks like a delete with the operator on the wrong side. Use \`-${trailingDash[1]}\` to delete that line.`,
492
+ );
493
+ }
291
494
  throw new Error(
292
495
  `Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
293
496
  );
294
497
  }
295
498
 
499
+ // Lines that look like recognized atom ops. Used to delimit range-replace
500
+ // recovery continuation: after `LidA..LidB=TEXT` (or legacy `|` separator),
501
+ // is treated as literal replacement text for backward compatibility.
502
+ const OP_LINE_HEAD_RE = /^([+\-@$^!]|[1-9]\d*[a-z]{2}|[ \t]*$)/;
503
+ const RANGE_CONTINUATION_SENTINEL = "\u0000";
504
+
505
+ function isRangeReplaceStart(line: string): boolean {
506
+ return /^[1-9]\d*[a-z]{2}\.\.[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(line);
507
+ }
508
+
509
+ // A single-line `Lid=TEXT` (or legacy `Lid|TEXT`, with optional leading `@`)
510
+ // also opens a replacement that `\TEXT` continuation lines may extend. The
511
+ // continuation lines become inserts at the cursor (which sits on the just-set
512
+ // line), turning `Lid=A` + `\B` + `\C` into "set the line to A, then insert B
513
+ // and C below it" — i.e. a multi-line rewrite of one anchor without forcing
514
+ // the user to switch to the `LidA..LidB=` range form.
515
+ function isReplaceStart(line: string): boolean {
516
+ if (isRangeReplaceStart(line)) return true;
517
+ const stripped = line.startsWith("@") ? line.slice(1) : line;
518
+ return /^[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(stripped);
519
+ }
520
+
521
+ // Explicit continuation uses `\TEXT` after a replacement op (`Lid=FIRST` or
522
+ // `LidA..LidB=FIRST`). The leading backslash is the continuation marker; the
523
+ // rest of the line is inserted literally, so `\\TEXT` inserts a line starting
524
+ // with `\TEXT`. Raw unprefixed continuation remains an undocumented
525
+ // best-effort recovery for range replacements only, kept for old transcripts.
526
+ function preprocessRangeReplaceContinuation(diff: string): string {
527
+ const lines = diff.split("\n");
528
+ let inRangeReplace = false;
529
+ let inReplace = false;
530
+ for (let i = 0; i < lines.length; i++) {
531
+ const rawLine = lines[i];
532
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
533
+
534
+ if (line.startsWith("\\")) {
535
+ if (!inReplace) {
536
+ throw new Error(
537
+ `Diff line ${i + 1}: \\TEXT continuation is only valid immediately after a Lid=TEXT or LidA..LidB=FIRST_LINE replacement.`,
538
+ );
539
+ }
540
+ lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine.slice(1)}`;
541
+ continue;
542
+ }
543
+
544
+ if (inRangeReplace) {
545
+ if (line.length === 0 || OP_LINE_HEAD_RE.test(line)) {
546
+ inRangeReplace = isRangeReplaceStart(line);
547
+ inReplace = isReplaceStart(line);
548
+ continue;
549
+ }
550
+
551
+ lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine}`;
552
+ continue;
553
+ }
554
+
555
+ inRangeReplace = isRangeReplaceStart(line);
556
+ inReplace = isReplaceStart(line);
557
+ }
558
+ return lines.join("\n");
559
+ }
560
+
296
561
  function tokenizeDiff(diff: string): ParsedStmt[] {
297
562
  const out: ParsedStmt[] = [];
298
- const lines = diff.split("\n");
563
+ const lines = preprocessRangeReplaceContinuation(diff).split("\n");
299
564
  for (let i = 0; i < lines.length; i++) {
300
565
  const lineNum = i + 1;
301
566
  const stmts = parseDiffLine(lines[i], lineNum);
@@ -327,11 +592,14 @@ function tokenizeDiff(diff: string): ParsedStmt[] {
327
592
  // Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
328
593
  // hunks and reorder so adds land at the FIRST delete's slot (block
329
594
  // replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
330
- // `set`. Standalone `+Lid|TEXT` and `+Lid|TEXT` referencing a Lid not in
595
+ // `set`; malformed standalone or mismatched `+Lid|TEXT`/`+Lid=TEXT` lines
596
+ // throw instead of silently dropping the Lid prefix.
331
597
  function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
332
598
  const isDelete = (s: ParsedStmt): boolean =>
333
599
  (s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
334
600
  const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
601
+ const formatDiffishAdd = (stmt: DiffishAddStmt): string =>
602
+ `+${stmt.anchor.line}${stmt.anchor.hash}${stmt.separator}${stmt.text}`;
335
603
  const out: ParsedStmt[] = [];
336
604
  let i = 0;
337
605
  while (i < stmts.length) {
@@ -340,7 +608,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
340
608
  if (stmt.kind === "diffish_add") {
341
609
  const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
342
610
  throw new Error(
343
- `Diff line ${stmt.lineNum}: \`+${lid}|...\` looks like a unified-diff replacement marker. Use \`${lid}=TEXT\` to replace, or precede with \`-${lid}\` to delete-then-replace.`,
611
+ `Diff line ${stmt.lineNum}: \`${formatDiffishAdd(stmt)}\` is unified-diff syntax, not edit syntax. To replace a line, use \`${lid}=TEXT\`; to insert literal text, use \`+TEXT\` without a Lid prefix.`,
344
612
  );
345
613
  }
346
614
  out.push(stmt);
@@ -368,7 +636,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
368
636
  const lid = `${add.anchor.line}${add.anchor.hash}`;
369
637
  if (!deletedLids.has(lid)) {
370
638
  throw new Error(
371
- `Diff line ${add.lineNum}: \`+${lid}|...\` references a Lid that was not deleted in the preceding run. Use \`${lid}=TEXT\` to replace, or precede with \`-${lid}\`.`,
639
+ `Diff line ${add.lineNum}: \`${formatDiffishAdd(add)}\` references a Lid not in the preceding delete run. Use plain \`+TEXT\` for replacement lines, or delete \`${lid}\` before using a unified-diff recovery line for that Lid.`,
372
640
  );
373
641
  }
374
642
  }
@@ -444,7 +712,12 @@ function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
444
712
  // ═══════════════════════════════════════════════════════════════════════════
445
713
 
446
714
  export function parseAtom(diff: string): AtomEdit[] {
715
+ return parseAtomWithWarnings(diff).edits;
716
+ }
717
+
718
+ export function parseAtomWithWarnings(diff: string): { edits: AtomEdit[]; warnings: string[] } {
447
719
  const edits: AtomEdit[] = [];
720
+ const warnings: string[] = [];
448
721
  let cursor: AtomCursor = { kind: "eof" };
449
722
  let index = 0;
450
723
 
@@ -473,7 +746,12 @@ export function parseAtom(diff: string): AtomEdit[] {
473
746
  }
474
747
 
475
748
  if (stmt.kind === "diffish_add") {
476
- throw new Error("Internal atom error: unresolved diff-ish add reached parseAtom.");
749
+ throw new Error("Internal edit error: unresolved diff-ish add reached parser.");
750
+ }
751
+
752
+ if (stmt.kind === "before_anchor") {
753
+ cursor = { kind: "before_anchor", anchor: makeAnchor(stmt.anchor) };
754
+ continue;
477
755
  }
478
756
 
479
757
  const anchor = makeAnchor(stmt.anchor);
@@ -502,7 +780,7 @@ export function parseAtom(diff: string): AtomEdit[] {
502
780
  index++;
503
781
  }
504
782
 
505
- return edits;
783
+ return { edits, warnings };
506
784
  }
507
785
 
508
786
  function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
@@ -523,7 +801,7 @@ function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
523
801
 
524
802
  function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
525
803
  if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
526
- if (edit.cursor.kind === "anchor") return [edit.cursor.anchor];
804
+ if (edit.cursor.kind === "anchor" || edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
527
805
  return [];
528
806
  }
529
807
 
@@ -535,6 +813,7 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
535
813
  if (anchor.line < 1 || anchor.line > fileLines.length) {
536
814
  throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
537
815
  }
816
+ if (anchor.hash === RANGE_INTERIOR_HASH) continue;
538
817
  const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
539
818
  if (actualHash === anchor.hash) continue;
540
819
 
@@ -658,8 +937,8 @@ function applyFileCursorInserts(
658
937
 
659
938
  function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
660
939
  if (edit.kind !== "insert") return edit.anchor;
661
- if (edit.cursor.kind !== "anchor") {
662
- throw new Error("Internal atom error: file-scoped insert reached anchor application.");
940
+ if (edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
941
+ throw new Error("Internal edit error: file-scoped insert reached anchor application.");
663
942
  }
664
943
  return edit.cursor.anchor;
665
944
  }
@@ -779,7 +1058,7 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
779
1058
  const anchorEdits: IndexedAnchorEdit[] = [];
780
1059
  const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
781
1060
  edits.forEach((edit, idx) => {
782
- if (edit.kind === "insert" && edit.cursor.kind !== "anchor") {
1061
+ if (edit.kind === "insert" && edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
783
1062
  fileInserts.push(edit);
784
1063
  return;
785
1064
  }
@@ -808,12 +1087,17 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
808
1087
  let replacement: string[] = [currentLine];
809
1088
  let replacementSet = false;
810
1089
  let anchorMutated = false;
1090
+ const beforeLines: string[] = [];
811
1091
  const afterLines: string[] = [];
812
1092
 
813
1093
  for (const { edit } of bucket) {
814
1094
  switch (edit.kind) {
815
1095
  case "insert":
816
- afterLines.push(edit.text);
1096
+ if (edit.cursor.kind === "before_anchor") {
1097
+ beforeLines.push(edit.text);
1098
+ } else {
1099
+ afterLines.push(edit.text);
1100
+ }
817
1101
  break;
818
1102
  case "set":
819
1103
  replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
@@ -832,7 +1116,10 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
832
1116
  }
833
1117
 
834
1118
  const replacementProducesNoChange =
835
- afterLines.length === 0 && replacement.length === 1 && replacement[0] === currentLine;
1119
+ beforeLines.length === 0 &&
1120
+ afterLines.length === 0 &&
1121
+ replacement.length === 1 &&
1122
+ replacement[0] === currentLine;
836
1123
  if (replacementProducesNoChange) {
837
1124
  const firstEdit = bucket[0]?.edit;
838
1125
  const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
@@ -848,14 +1135,14 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
848
1135
  continue;
849
1136
  }
850
1137
 
851
- const combined = [...replacement, ...afterLines];
1138
+ const combined = [...beforeLines, ...replacement, ...afterLines];
852
1139
  fileLines.splice(idx, 1, ...combined);
853
- if (anchorMutated) {
1140
+ if (anchorMutated || beforeLines.length > 0) {
854
1141
  trackFirstChanged(line);
855
1142
  } else if (afterLines.length > 0) {
856
1143
  trackFirstChanged(line + 1);
857
1144
  }
858
- if (!replacementSet && afterLines.length === 0) continue;
1145
+ if (!replacementSet && beforeLines.length === 0 && afterLines.length === 0) continue;
859
1146
  }
860
1147
 
861
1148
  const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
@@ -928,7 +1215,7 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
928
1215
  if (body.startsWith(" ")) body = body.slice(1);
929
1216
  const parsedPath = normalizeAtomPath(body, cwd);
930
1217
  if (parsedPath.length === 0) {
931
- throw new Error(`atom input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
1218
+ throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
932
1219
  }
933
1220
  return parsedPath;
934
1221
  }
@@ -936,21 +1223,21 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
936
1223
  function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
937
1224
  const trimmed = rawPath.trim();
938
1225
  if (trimmed.length === 0) {
939
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
1226
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
940
1227
  }
941
1228
 
942
1229
  const quote = trimmed[0];
943
1230
  if (quote === '"' || quote === "'") {
944
1231
  if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
945
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
1232
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
946
1233
  }
947
1234
  } else if (/\s/.test(trimmed)) {
948
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
1235
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
949
1236
  }
950
1237
 
951
1238
  const destination = normalizeAtomPath(trimmed, cwd);
952
1239
  if (destination.length === 0) {
953
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
1240
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
954
1241
  }
955
1242
  return destination;
956
1243
  }
@@ -965,11 +1252,11 @@ function parseAtomWholeFileOperationLine(
965
1252
  return { kind: "delete", lineNum };
966
1253
  }
967
1254
  if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
968
- throw new Error(`Atom line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
1255
+ throw new Error(`Diff line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
969
1256
  }
970
1257
 
971
1258
  if (line === MOVE_FILE_OPERATION) {
972
- throw new Error(`Atom line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
1259
+ throw new Error(`Diff line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
973
1260
  }
974
1261
  if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
975
1262
  const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
@@ -1001,7 +1288,7 @@ function getAtomWholeFileOperation(
1001
1288
  if (parsed) {
1002
1289
  if (operation) {
1003
1290
  throw new Error(
1004
- `Atom section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
1291
+ `Edit section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
1005
1292
  );
1006
1293
  }
1007
1294
  operation = parsed;
@@ -1014,7 +1301,7 @@ function getAtomWholeFileOperation(
1014
1301
 
1015
1302
  if (operation && hasLineEdit) {
1016
1303
  throw new Error(
1017
- `Atom section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
1304
+ `Edit section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
1018
1305
  );
1019
1306
  }
1020
1307
 
@@ -1032,8 +1319,10 @@ function containsRecognizableAtomOperations(input: string): boolean {
1032
1319
  if (line.length === 0) continue;
1033
1320
  if (line[0] === "+") return true;
1034
1321
  if (line === "$" || line === "^") return true;
1035
- if (/^- ?[1-9]\d*[a-z]{2}(?: .*)?$/.test(line)) return true;
1036
- if (/^@?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?$/.test(line)) return true;
1322
+ if (/^\$\+.*$/.test(line)) return true;
1323
+ if (/^\^[1-9]\d*[a-z]{2}(?:\+.*)?$/.test(line)) return true;
1324
+ if (/^- ?[1-9]\d*[a-z]{2}(?:\.\.[1-9]\d*[a-z]{2})?(?:[ \t]*[=|].*| .*)?$/.test(line)) return true;
1325
+ if (/^@?[1-9]\d*[a-z]{2}(?:\+.*|[ \t]*[=|].*|\.\.[1-9]\d*[a-z]{2}[ \t]*=.*)?$/.test(line)) return true;
1037
1326
  if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
1038
1327
  }
1039
1328
  return false;
@@ -1048,8 +1337,42 @@ function stripLeadingBlankLines(input: string): string {
1048
1337
  return lines.join("\n");
1049
1338
  }
1050
1339
 
1340
+ function normalizeStandaloneFileOpInput(input: string, cwd?: string): string | null {
1341
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1342
+ const lines = stripped.split("\n");
1343
+ let firstIdx = -1;
1344
+ for (let i = 0; i < lines.length; i++) {
1345
+ if (lines[i].replace(/\r$/, "").trim().length > 0) {
1346
+ firstIdx = i;
1347
+ break;
1348
+ }
1349
+ }
1350
+ if (firstIdx === -1) return null;
1351
+ const firstLine = lines[firstIdx].replace(/\r$/, "");
1352
+ const remaining = lines.slice(firstIdx + 1).join("\n");
1353
+ if (remaining.trim().length > 0) return null;
1354
+
1355
+ const rmMatch = /^!rm\s+(\S.*)$/.exec(firstLine);
1356
+ if (rmMatch) {
1357
+ const sourcePath = parseSingleAtomPathArgument(rmMatch[1], REMOVE_FILE_OPERATION, firstIdx + 1, cwd);
1358
+ return `${FILE_HEADER_PREFIX}${sourcePath}\n${REMOVE_FILE_OPERATION}`;
1359
+ }
1360
+
1361
+ const mvMatch = /^!mv\s+(\S+)\s+(\S.*)$/.exec(firstLine);
1362
+ if (mvMatch) {
1363
+ const sourcePath = parseSingleAtomPathArgument(mvMatch[1], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
1364
+ const destPath = parseSingleAtomPathArgument(mvMatch[2], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
1365
+ return `${FILE_HEADER_PREFIX}${sourcePath}\n${MOVE_FILE_OPERATION} ${destPath}`;
1366
+ }
1367
+
1368
+ return null;
1369
+ }
1370
+
1051
1371
  function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
1052
- if (hasAtomHeaderLine(input) || !options.path || !containsRecognizableAtomOperations(input)) {
1372
+ if (hasAtomHeaderLine(input)) return input;
1373
+ const standalone = normalizeStandaloneFileOpInput(input, options.cwd);
1374
+ if (standalone !== null) return standalone;
1375
+ if (!options.path || !containsRecognizableAtomOperations(input)) {
1053
1376
  return input;
1054
1377
  }
1055
1378
  const fallbackPath = normalizeAtomPath(options.path, options.cwd);
@@ -1082,10 +1405,11 @@ export function splitAtomInputs(input: string, options: SplitAtomOptions = {}):
1082
1405
  const lines = stripped.split("\n");
1083
1406
  const firstLine = (lines[0] ?? "").replace(/\r$/, "");
1084
1407
  if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
1408
+ const preview = JSON.stringify(firstLine.slice(0, 120));
1085
1409
  throw new Error(
1086
- `atom input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${JSON.stringify(
1087
- firstLine.slice(0, 120),
1088
- )}`,
1410
+ `input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${preview}.\n` +
1411
+ `Example: "${FILE_HEADER_PREFIX}src/foo.ts" then your edit ops on the following lines. ` +
1412
+ `To delete a file: "${FILE_HEADER_PREFIX}<path>\\n!rm". To rename: "${FILE_HEADER_PREFIX}<src>\\n!mv <dest>".`,
1089
1413
  );
1090
1414
  }
1091
1415
 
@@ -1146,7 +1470,13 @@ async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
1146
1470
  }
1147
1471
 
1148
1472
  function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
1149
- return edits.some(edit => edit.kind === "set" || edit.kind === "delete" || edit.cursor.kind === "anchor");
1473
+ return edits.some(
1474
+ edit =>
1475
+ edit.kind === "set" ||
1476
+ edit.kind === "delete" ||
1477
+ edit.cursor.kind === "anchor" ||
1478
+ edit.cursor.kind === "before_anchor",
1479
+ );
1150
1480
  }
1151
1481
 
1152
1482
  function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
@@ -1162,6 +1492,12 @@ function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string
1162
1492
  })
1163
1493
  .join("\n");
1164
1494
  diagnostic += `\n${details}`;
1495
+ const setNoops = result.noopEdits.filter(e => e.reason.startsWith("replacement is identical"));
1496
+ if (setNoops.length > 0) {
1497
+ diagnostic +=
1498
+ "\n\nHint: each `Lid=TEXT` you emit MUST contain text that differs from the line currently anchored by Lid. " +
1499
+ "Do not echo lines back from `read` output unchanged. If you intended to leave a line as-is, omit it from the patch.";
1500
+ }
1165
1501
  }
1166
1502
  return diagnostic;
1167
1503
  }
@@ -1219,6 +1555,60 @@ async function executeAtomWholeFileOperation(
1219
1555
  };
1220
1556
  }
1221
1557
 
1558
+ async function preflightAtomSection(options: ExecuteAtomSingleOptions & AtomInputSection): Promise<void> {
1559
+ const { session, path: sectionPath, diff } = options;
1560
+ if (options.wholeFileOperation) {
1561
+ const { wholeFileOperation } = options;
1562
+ const absolutePath = resolvePlanPath(session, sectionPath);
1563
+ if (sectionPath.endsWith(".ipynb")) {
1564
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1565
+ }
1566
+ if (wholeFileOperation.kind === "delete") {
1567
+ enforcePlanModeWrite(session, sectionPath, { op: "delete" });
1568
+ await assertEditableFile(absolutePath, sectionPath);
1569
+ return;
1570
+ }
1571
+
1572
+ const destinationPath = wholeFileOperation.destination;
1573
+ if (destinationPath.endsWith(".ipynb")) {
1574
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1575
+ }
1576
+ enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
1577
+ const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
1578
+ if (absoluteDestinationPath === absolutePath) {
1579
+ throw new Error("rename path is the same as source path");
1580
+ }
1581
+ await assertEditableFile(absolutePath, sectionPath);
1582
+ return;
1583
+ }
1584
+
1585
+ const { edits } = parseAtomWithWarnings(diff);
1586
+ if (edits.length === 0 && diff.trim().length > 0) {
1587
+ throw new Error(formatNoAtomEditDiagnostic(sectionPath, diff));
1588
+ }
1589
+
1590
+ enforcePlanModeWrite(session, sectionPath, { op: "update" });
1591
+ if (sectionPath.endsWith(".ipynb") && edits.length > 0) {
1592
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1593
+ }
1594
+
1595
+ const absolutePath = resolvePlanPath(session, sectionPath);
1596
+ const source = await readAtomFile(absolutePath);
1597
+ if (!source.exists && hasAnchorScopedEdit(edits)) {
1598
+ throw new Error(`File not found: ${sectionPath}`);
1599
+ }
1600
+ if (source.exists) {
1601
+ assertEditableFileContent(source.rawContent, sectionPath);
1602
+ }
1603
+
1604
+ const { text } = stripBom(source.rawContent);
1605
+ const originalNormalized = normalizeToLF(text);
1606
+ const result = applyAtomEdits(originalNormalized, edits);
1607
+ if (originalNormalized === result.lines && (result.noopEdits?.length ?? 0) === 0) {
1608
+ throw new Error(formatNoChangeDiagnostic(sectionPath, result));
1609
+ }
1610
+ }
1611
+
1222
1612
  async function executeAtomSection(
1223
1613
  options: ExecuteAtomSingleOptions & AtomInputSection,
1224
1614
  ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
@@ -1227,7 +1617,7 @@ async function executeAtomSection(
1227
1617
  return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
1228
1618
  }
1229
1619
 
1230
- const edits = parseAtom(diff);
1620
+ const { edits, warnings: parseWarnings } = parseAtomWithWarnings(diff);
1231
1621
  if (edits.length === 0 && diff.trim().length > 0) {
1232
1622
  throw new Error(formatNoAtomEditDiagnostic(path, diff));
1233
1623
  }
@@ -1253,7 +1643,18 @@ async function executeAtomSection(
1253
1643
  const originalNormalized = normalizeToLF(text);
1254
1644
  const result = applyAtomEdits(originalNormalized, edits);
1255
1645
  if (originalNormalized === result.lines) {
1256
- throw new Error(formatNoChangeDiagnostic(path, result));
1646
+ const allNoop = (result.noopEdits?.length ?? 0) > 0;
1647
+ if (!allNoop) {
1648
+ throw new Error(formatNoChangeDiagnostic(path, result));
1649
+ }
1650
+ // Every edit was a no-op (TEXT identical to the anchored line). Returning
1651
+ // success here breaks retry loops where models hammer the same `Lid=TEXT`
1652
+ // when TEXT happens to already match. The response makes the no-op
1653
+ // explicit so the model knows nothing changed and to move on.
1654
+ return {
1655
+ content: [{ type: "text", text: formatNoChangeDiagnostic(path, result) }],
1656
+ details: { diff: "", op: "update", meta: outputMeta().get() },
1657
+ };
1257
1658
  }
1258
1659
 
1259
1660
  const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
@@ -1272,7 +1673,8 @@ async function executeAtomSection(
1272
1673
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1273
1674
  .get();
1274
1675
  const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1275
- const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
1676
+ const allWarnings = [...parseWarnings, ...(result.warnings ?? [])];
1677
+ const warningsBlock = allWarnings.length > 0 ? `\n\nWarnings:\n${allWarnings.join("\n")}` : "";
1276
1678
  const previewBlock = preview.preview ? `\n${preview.preview}` : "";
1277
1679
  const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
1278
1680
 
@@ -1302,6 +1704,10 @@ export async function executeAtomSingle(
1302
1704
  return executeAtomSection({ ...options, ...section });
1303
1705
  }
1304
1706
 
1707
+ for (const section of sections) {
1708
+ await preflightAtomSection({ ...options, ...section });
1709
+ }
1710
+
1305
1711
  const results = [];
1306
1712
  for (const section of sections) {
1307
1713
  results.push({