@oh-my-pi/pi-coding-agent 14.5.8 → 14.5.10

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 (58) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +14 -19
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.lark +7 -5
  9. package/src/edit/modes/atom.ts +510 -73
  10. package/src/edit/modes/hashline.ts +172 -91
  11. package/src/extensibility/extensions/runner.ts +34 -1
  12. package/src/extensibility/extensions/types.ts +8 -0
  13. package/src/lsp/client.ts +27 -35
  14. package/src/lsp/index.ts +2 -4
  15. package/src/lsp/render.ts +0 -3
  16. package/src/lsp/types.ts +1 -4
  17. package/src/lsp/utils.ts +18 -14
  18. package/src/memories/index.ts +5 -0
  19. package/src/modes/components/settings-defs.ts +1 -1
  20. package/src/modes/controllers/command-controller.ts +17 -0
  21. package/src/modes/controllers/input-controller.ts +7 -1
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +57 -26
  24. package/src/modes/theme/theme.ts +10 -1
  25. package/src/modes/types.ts +5 -3
  26. package/src/modes/utils/context-usage.ts +294 -0
  27. package/src/modes/utils/ui-helpers.ts +19 -6
  28. package/src/prompts/system/auto-continue.md +1 -0
  29. package/src/prompts/tools/atom.md +99 -44
  30. package/src/prompts/tools/exit-plan-mode.md +5 -39
  31. package/src/prompts/tools/github.md +3 -3
  32. package/src/prompts/tools/lsp.md +2 -3
  33. package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
  34. package/src/prompts/tools/task.md +34 -147
  35. package/src/prompts/tools/todo-write.md +22 -64
  36. package/src/sdk.ts +13 -2
  37. package/src/session/agent-session.ts +175 -79
  38. package/src/session/compaction/compaction.ts +35 -22
  39. package/src/session/session-dump-format.ts +1 -0
  40. package/src/session/session-manager.ts +19 -2
  41. package/src/slash-commands/builtin-registry.ts +12 -5
  42. package/src/tools/bash.ts +9 -4
  43. package/src/tools/debug.ts +57 -70
  44. package/src/tools/gh.ts +267 -119
  45. package/src/tools/index.ts +7 -7
  46. package/src/tools/{run-command → recipe}/index.ts +19 -19
  47. package/src/tools/recipe/render.ts +19 -0
  48. package/src/tools/{run-command → recipe}/runner.ts +28 -7
  49. package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
  50. package/src/tools/renderers.ts +2 -2
  51. package/src/utils/git.ts +61 -2
  52. package/src/web/search/providers/searxng.ts +71 -13
  53. package/src/tools/run-command/render.ts +0 -18
  54. /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
  55. /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
  56. /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
  57. /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
  58. /package/src/tools/{run-command → recipe}/runners/task.ts +0 -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,105 @@ 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
+ // Lookahead used by the blank-line forgiveness rule below: returns true when
522
+ // the first non-blank line at or after `start` is a `\TEXT` continuation.
523
+ function nextNonBlankIsBackslash(lines: readonly string[], start: number): boolean {
524
+ for (let j = start; j < lines.length; j++) {
525
+ const peek = lines[j].endsWith("\r") ? lines[j].slice(0, -1) : lines[j];
526
+ if (peek.length === 0) continue;
527
+ return peek.startsWith("\\");
528
+ }
529
+ return false;
530
+ }
531
+
532
+ // Explicit continuation uses `\TEXT` after a replacement op (`Lid=FIRST` or
533
+ // `LidA..LidB=FIRST`). The leading backslash is the continuation marker; the
534
+ // rest of the line is inserted literally, so `\\TEXT` inserts a line starting
535
+ // with `\TEXT`. As a forgiveness rule, a literal blank line inside an active
536
+ // replacement that is itself followed (possibly through more blanks) by another
537
+ // `\TEXT` continuation is treated as an implicit `\` blank insert — authors
538
+ // frequently drop a real blank between `\TEXT` lines instead of writing `\`.
539
+ // Raw unprefixed continuation remains an undocumented best-effort recovery for
540
+ // range replacements only, kept for old transcripts.
541
+ function preprocessRangeReplaceContinuation(diff: string): string {
542
+ const lines = diff.split("\n");
543
+ let inRangeReplace = false;
544
+ let inReplace = false;
545
+ for (let i = 0; i < lines.length; i++) {
546
+ const rawLine = lines[i];
547
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
548
+
549
+ if (line.startsWith("\\")) {
550
+ if (!inReplace) {
551
+ throw new Error(
552
+ `Diff line ${i + 1}: \\TEXT continuation is only valid immediately after a Lid=TEXT or LidA..LidB=FIRST_LINE replacement.`,
553
+ );
554
+ }
555
+ lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine.slice(1)}`;
556
+ continue;
557
+ }
558
+
559
+ // Forgiveness: a blank line inside an active replacement that is followed
560
+ // by another `\TEXT` continuation is treated as an implicit `\` blank
561
+ // insert. Keeps the replacement open across the blank.
562
+ if (inReplace && line.length === 0 && nextNonBlankIsBackslash(lines, i + 1)) {
563
+ lines[i] = `+${RANGE_CONTINUATION_SENTINEL}`;
564
+ continue;
565
+ }
566
+
567
+ if (inRangeReplace) {
568
+ if (line.length === 0 || OP_LINE_HEAD_RE.test(line)) {
569
+ inRangeReplace = isRangeReplaceStart(line);
570
+ inReplace = isReplaceStart(line);
571
+ continue;
572
+ }
573
+
574
+ lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine}`;
575
+ continue;
576
+ }
577
+
578
+ inRangeReplace = isRangeReplaceStart(line);
579
+ inReplace = isReplaceStart(line);
580
+ }
581
+ return lines.join("\n");
582
+ }
583
+
296
584
  function tokenizeDiff(diff: string): ParsedStmt[] {
297
585
  const out: ParsedStmt[] = [];
298
- const lines = diff.split("\n");
586
+ const lines = preprocessRangeReplaceContinuation(diff).split("\n");
299
587
  for (let i = 0; i < lines.length; i++) {
300
588
  const lineNum = i + 1;
301
589
  const stmts = parseDiffLine(lines[i], lineNum);
@@ -327,11 +615,14 @@ function tokenizeDiff(diff: string): ParsedStmt[] {
327
615
  // Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
328
616
  // hunks and reorder so adds land at the FIRST delete's slot (block
329
617
  // replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
330
- // `set`. Standalone `+Lid|TEXT` and `+Lid|TEXT` referencing a Lid not in
618
+ // `set`; malformed standalone or mismatched `+Lid|TEXT`/`+Lid=TEXT` lines
619
+ // throw instead of silently dropping the Lid prefix.
331
620
  function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
332
621
  const isDelete = (s: ParsedStmt): boolean =>
333
622
  (s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
334
623
  const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
624
+ const formatDiffishAdd = (stmt: DiffishAddStmt): string =>
625
+ `+${stmt.anchor.line}${stmt.anchor.hash}${stmt.separator}${stmt.text}`;
335
626
  const out: ParsedStmt[] = [];
336
627
  let i = 0;
337
628
  while (i < stmts.length) {
@@ -340,7 +631,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
340
631
  if (stmt.kind === "diffish_add") {
341
632
  const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
342
633
  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.`,
634
+ `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
635
  );
345
636
  }
346
637
  out.push(stmt);
@@ -368,7 +659,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
368
659
  const lid = `${add.anchor.line}${add.anchor.hash}`;
369
660
  if (!deletedLids.has(lid)) {
370
661
  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}\`.`,
662
+ `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
663
  );
373
664
  }
374
665
  }
@@ -444,7 +735,12 @@ function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
444
735
  // ═══════════════════════════════════════════════════════════════════════════
445
736
 
446
737
  export function parseAtom(diff: string): AtomEdit[] {
738
+ return parseAtomWithWarnings(diff).edits;
739
+ }
740
+
741
+ export function parseAtomWithWarnings(diff: string): { edits: AtomEdit[]; warnings: string[] } {
447
742
  const edits: AtomEdit[] = [];
743
+ const warnings: string[] = [];
448
744
  let cursor: AtomCursor = { kind: "eof" };
449
745
  let index = 0;
450
746
 
@@ -473,7 +769,12 @@ export function parseAtom(diff: string): AtomEdit[] {
473
769
  }
474
770
 
475
771
  if (stmt.kind === "diffish_add") {
476
- throw new Error("Internal atom error: unresolved diff-ish add reached parseAtom.");
772
+ throw new Error("Internal edit error: unresolved diff-ish add reached parser.");
773
+ }
774
+
775
+ if (stmt.kind === "before_anchor") {
776
+ cursor = { kind: "before_anchor", anchor: makeAnchor(stmt.anchor) };
777
+ continue;
477
778
  }
478
779
 
479
780
  const anchor = makeAnchor(stmt.anchor);
@@ -502,7 +803,7 @@ export function parseAtom(diff: string): AtomEdit[] {
502
803
  index++;
503
804
  }
504
805
 
505
- return edits;
806
+ return { edits, warnings };
506
807
  }
507
808
 
508
809
  function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
@@ -523,7 +824,7 @@ function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
523
824
 
524
825
  function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
525
826
  if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
526
- if (edit.cursor.kind === "anchor") return [edit.cursor.anchor];
827
+ if (edit.cursor.kind === "anchor" || edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
527
828
  return [];
528
829
  }
529
830
 
@@ -535,6 +836,7 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
535
836
  if (anchor.line < 1 || anchor.line > fileLines.length) {
536
837
  throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
537
838
  }
839
+ if (anchor.hash === RANGE_INTERIOR_HASH) continue;
538
840
  const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
539
841
  if (actualHash === anchor.hash) continue;
540
842
 
@@ -658,8 +960,8 @@ function applyFileCursorInserts(
658
960
 
659
961
  function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
660
962
  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.");
963
+ if (edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
964
+ throw new Error("Internal edit error: file-scoped insert reached anchor application.");
663
965
  }
664
966
  return edit.cursor.anchor;
665
967
  }
@@ -670,6 +972,9 @@ function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
670
972
  // missed one delete on the front or back of the deletion range, leaving a
671
973
  // stale copy of a line the agent already re-emitted (e.g. inserting a new
672
974
  // closing `}` while the original `}` was never deleted, producing `}\n}`).
975
+ // A single edit may damage multiple unrelated segments (e.g. two block
976
+ // rewrites that each missed their trailing `}`), so detection and auto-fix
977
+ // operate on every new adjacent duplicate at once.
673
978
  //
674
979
  // Auto-fix is gated on bracket balance: we only remove the duplicate line if
675
980
  // its removal restores the original file's `{}`/`()`/`[]` delta. That makes
@@ -729,23 +1034,28 @@ function detectAndAutoFixDuplicates(
729
1034
 
730
1035
  const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
731
1036
 
732
- // Auto-fix only when there is exactly one new adjacent duplicate AND the
733
- // edit shifted bracket balance. Removing one of the two identical lines
734
- // must restore the original delta exactly.
735
- if (newDupPositions.length === 1) {
736
- const pos = newDupPositions[0];
737
- const origBalance = computeBalance(originalLines);
738
- const finalBalance = computeBalance(finalLines);
739
- if (!balancesEqual(origBalance, finalBalance)) {
740
- const trial = finalLines.slice(0, pos).concat(finalLines.slice(pos + 1));
741
- if (balancesEqual(computeBalance(trial), origBalance)) {
742
- return {
743
- fixed: trial,
744
- warnings: [
745
- `Auto-fixed: removed duplicate line ${pos + 1} (${formatPreview(finalLines[pos])}); the edit left two adjacent identical lines and bracket balance was off. Verify the result.`,
746
- ],
747
- };
748
- }
1037
+ // Auto-fix when removing one line from each new adjacent duplicate pair
1038
+ // collectively restores the original bracket balance. The balance check is
1039
+ // the safety gate: if we over- or under-correct (e.g. when 3+ adjacent
1040
+ // identical lines confuse the per-pair scan), the trial balance will not
1041
+ // match and we fall through to warnings.
1042
+ const origBalance = computeBalance(originalLines);
1043
+ const finalBalance = computeBalance(finalLines);
1044
+ if (!balancesEqual(origBalance, finalBalance)) {
1045
+ const trial = finalLines.slice();
1046
+ // Remove in reverse so earlier indices remain valid.
1047
+ for (let i = newDupPositions.length - 1; i >= 0; i--) {
1048
+ trial.splice(newDupPositions[i], 1);
1049
+ }
1050
+ if (balancesEqual(computeBalance(trial), origBalance)) {
1051
+ const previews = newDupPositions.map(pos => `${pos + 1} (${formatPreview(finalLines[pos])})`).join(", ");
1052
+ const noun = newDupPositions.length === 1 ? "duplicate line" : "duplicate lines";
1053
+ return {
1054
+ fixed: trial,
1055
+ warnings: [
1056
+ `Auto-fixed: removed ${noun} ${previews}; the edit left adjacent identical lines and bracket balance was off. Verify the result.`,
1057
+ ],
1058
+ };
749
1059
  }
750
1060
  }
751
1061
 
@@ -779,7 +1089,7 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
779
1089
  const anchorEdits: IndexedAnchorEdit[] = [];
780
1090
  const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
781
1091
  edits.forEach((edit, idx) => {
782
- if (edit.kind === "insert" && edit.cursor.kind !== "anchor") {
1092
+ if (edit.kind === "insert" && edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
783
1093
  fileInserts.push(edit);
784
1094
  return;
785
1095
  }
@@ -808,12 +1118,17 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
808
1118
  let replacement: string[] = [currentLine];
809
1119
  let replacementSet = false;
810
1120
  let anchorMutated = false;
1121
+ const beforeLines: string[] = [];
811
1122
  const afterLines: string[] = [];
812
1123
 
813
1124
  for (const { edit } of bucket) {
814
1125
  switch (edit.kind) {
815
1126
  case "insert":
816
- afterLines.push(edit.text);
1127
+ if (edit.cursor.kind === "before_anchor") {
1128
+ beforeLines.push(edit.text);
1129
+ } else {
1130
+ afterLines.push(edit.text);
1131
+ }
817
1132
  break;
818
1133
  case "set":
819
1134
  replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
@@ -832,7 +1147,10 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
832
1147
  }
833
1148
 
834
1149
  const replacementProducesNoChange =
835
- afterLines.length === 0 && replacement.length === 1 && replacement[0] === currentLine;
1150
+ beforeLines.length === 0 &&
1151
+ afterLines.length === 0 &&
1152
+ replacement.length === 1 &&
1153
+ replacement[0] === currentLine;
836
1154
  if (replacementProducesNoChange) {
837
1155
  const firstEdit = bucket[0]?.edit;
838
1156
  const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
@@ -848,14 +1166,14 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
848
1166
  continue;
849
1167
  }
850
1168
 
851
- const combined = [...replacement, ...afterLines];
1169
+ const combined = [...beforeLines, ...replacement, ...afterLines];
852
1170
  fileLines.splice(idx, 1, ...combined);
853
- if (anchorMutated) {
1171
+ if (anchorMutated || beforeLines.length > 0) {
854
1172
  trackFirstChanged(line);
855
1173
  } else if (afterLines.length > 0) {
856
1174
  trackFirstChanged(line + 1);
857
1175
  }
858
- if (!replacementSet && afterLines.length === 0) continue;
1176
+ if (!replacementSet && beforeLines.length === 0 && afterLines.length === 0) continue;
859
1177
  }
860
1178
 
861
1179
  const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
@@ -928,7 +1246,7 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
928
1246
  if (body.startsWith(" ")) body = body.slice(1);
929
1247
  const parsedPath = normalizeAtomPath(body, cwd);
930
1248
  if (parsedPath.length === 0) {
931
- throw new Error(`atom input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
1249
+ throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
932
1250
  }
933
1251
  return parsedPath;
934
1252
  }
@@ -936,21 +1254,21 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
936
1254
  function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
937
1255
  const trimmed = rawPath.trim();
938
1256
  if (trimmed.length === 0) {
939
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
1257
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
940
1258
  }
941
1259
 
942
1260
  const quote = trimmed[0];
943
1261
  if (quote === '"' || quote === "'") {
944
1262
  if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
945
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
1263
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
946
1264
  }
947
1265
  } else if (/\s/.test(trimmed)) {
948
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
1266
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
949
1267
  }
950
1268
 
951
1269
  const destination = normalizeAtomPath(trimmed, cwd);
952
1270
  if (destination.length === 0) {
953
- throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
1271
+ throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
954
1272
  }
955
1273
  return destination;
956
1274
  }
@@ -965,11 +1283,11 @@ function parseAtomWholeFileOperationLine(
965
1283
  return { kind: "delete", lineNum };
966
1284
  }
967
1285
  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.`);
1286
+ throw new Error(`Diff line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
969
1287
  }
970
1288
 
971
1289
  if (line === MOVE_FILE_OPERATION) {
972
- throw new Error(`Atom line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
1290
+ throw new Error(`Diff line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
973
1291
  }
974
1292
  if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
975
1293
  const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
@@ -1001,7 +1319,7 @@ function getAtomWholeFileOperation(
1001
1319
  if (parsed) {
1002
1320
  if (operation) {
1003
1321
  throw new Error(
1004
- `Atom section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
1322
+ `Edit section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
1005
1323
  );
1006
1324
  }
1007
1325
  operation = parsed;
@@ -1014,7 +1332,7 @@ function getAtomWholeFileOperation(
1014
1332
 
1015
1333
  if (operation && hasLineEdit) {
1016
1334
  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.`,
1335
+ `Edit section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
1018
1336
  );
1019
1337
  }
1020
1338
 
@@ -1032,8 +1350,10 @@ function containsRecognizableAtomOperations(input: string): boolean {
1032
1350
  if (line.length === 0) continue;
1033
1351
  if (line[0] === "+") return true;
1034
1352
  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;
1353
+ if (/^\$\+.*$/.test(line)) return true;
1354
+ if (/^\^[1-9]\d*[a-z]{2}(?:\+.*)?$/.test(line)) return true;
1355
+ if (/^- ?[1-9]\d*[a-z]{2}(?:\.\.[1-9]\d*[a-z]{2})?(?:[ \t]*[=|].*| .*)?$/.test(line)) return true;
1356
+ if (/^@?[1-9]\d*[a-z]{2}(?:\+.*|[ \t]*[=|].*|\.\.[1-9]\d*[a-z]{2}[ \t]*=.*)?$/.test(line)) return true;
1037
1357
  if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
1038
1358
  }
1039
1359
  return false;
@@ -1048,8 +1368,42 @@ function stripLeadingBlankLines(input: string): string {
1048
1368
  return lines.join("\n");
1049
1369
  }
1050
1370
 
1371
+ function normalizeStandaloneFileOpInput(input: string, cwd?: string): string | null {
1372
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1373
+ const lines = stripped.split("\n");
1374
+ let firstIdx = -1;
1375
+ for (let i = 0; i < lines.length; i++) {
1376
+ if (lines[i].replace(/\r$/, "").trim().length > 0) {
1377
+ firstIdx = i;
1378
+ break;
1379
+ }
1380
+ }
1381
+ if (firstIdx === -1) return null;
1382
+ const firstLine = lines[firstIdx].replace(/\r$/, "");
1383
+ const remaining = lines.slice(firstIdx + 1).join("\n");
1384
+ if (remaining.trim().length > 0) return null;
1385
+
1386
+ const rmMatch = /^!rm\s+(\S.*)$/.exec(firstLine);
1387
+ if (rmMatch) {
1388
+ const sourcePath = parseSingleAtomPathArgument(rmMatch[1], REMOVE_FILE_OPERATION, firstIdx + 1, cwd);
1389
+ return `${FILE_HEADER_PREFIX}${sourcePath}\n${REMOVE_FILE_OPERATION}`;
1390
+ }
1391
+
1392
+ const mvMatch = /^!mv\s+(\S+)\s+(\S.*)$/.exec(firstLine);
1393
+ if (mvMatch) {
1394
+ const sourcePath = parseSingleAtomPathArgument(mvMatch[1], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
1395
+ const destPath = parseSingleAtomPathArgument(mvMatch[2], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
1396
+ return `${FILE_HEADER_PREFIX}${sourcePath}\n${MOVE_FILE_OPERATION} ${destPath}`;
1397
+ }
1398
+
1399
+ return null;
1400
+ }
1401
+
1051
1402
  function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
1052
- if (hasAtomHeaderLine(input) || !options.path || !containsRecognizableAtomOperations(input)) {
1403
+ if (hasAtomHeaderLine(input)) return input;
1404
+ const standalone = normalizeStandaloneFileOpInput(input, options.cwd);
1405
+ if (standalone !== null) return standalone;
1406
+ if (!options.path || !containsRecognizableAtomOperations(input)) {
1053
1407
  return input;
1054
1408
  }
1055
1409
  const fallbackPath = normalizeAtomPath(options.path, options.cwd);
@@ -1082,10 +1436,11 @@ export function splitAtomInputs(input: string, options: SplitAtomOptions = {}):
1082
1436
  const lines = stripped.split("\n");
1083
1437
  const firstLine = (lines[0] ?? "").replace(/\r$/, "");
1084
1438
  if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
1439
+ const preview = JSON.stringify(firstLine.slice(0, 120));
1085
1440
  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
- )}`,
1441
+ `input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${preview}.\n` +
1442
+ `Example: "${FILE_HEADER_PREFIX}src/foo.ts" then your edit ops on the following lines. ` +
1443
+ `To delete a file: "${FILE_HEADER_PREFIX}<path>\\n!rm". To rename: "${FILE_HEADER_PREFIX}<src>\\n!mv <dest>".`,
1089
1444
  );
1090
1445
  }
1091
1446
 
@@ -1146,7 +1501,13 @@ async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
1146
1501
  }
1147
1502
 
1148
1503
  function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
1149
- return edits.some(edit => edit.kind === "set" || edit.kind === "delete" || edit.cursor.kind === "anchor");
1504
+ return edits.some(
1505
+ edit =>
1506
+ edit.kind === "set" ||
1507
+ edit.kind === "delete" ||
1508
+ edit.cursor.kind === "anchor" ||
1509
+ edit.cursor.kind === "before_anchor",
1510
+ );
1150
1511
  }
1151
1512
 
1152
1513
  function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
@@ -1162,6 +1523,12 @@ function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string
1162
1523
  })
1163
1524
  .join("\n");
1164
1525
  diagnostic += `\n${details}`;
1526
+ const setNoops = result.noopEdits.filter(e => e.reason.startsWith("replacement is identical"));
1527
+ if (setNoops.length > 0) {
1528
+ diagnostic +=
1529
+ "\n\nHint: each `Lid=TEXT` you emit MUST contain text that differs from the line currently anchored by Lid. " +
1530
+ "Do not echo lines back from `read` output unchanged. If you intended to leave a line as-is, omit it from the patch.";
1531
+ }
1165
1532
  }
1166
1533
  return diagnostic;
1167
1534
  }
@@ -1219,6 +1586,60 @@ async function executeAtomWholeFileOperation(
1219
1586
  };
1220
1587
  }
1221
1588
 
1589
+ async function preflightAtomSection(options: ExecuteAtomSingleOptions & AtomInputSection): Promise<void> {
1590
+ const { session, path: sectionPath, diff } = options;
1591
+ if (options.wholeFileOperation) {
1592
+ const { wholeFileOperation } = options;
1593
+ const absolutePath = resolvePlanPath(session, sectionPath);
1594
+ if (sectionPath.endsWith(".ipynb")) {
1595
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1596
+ }
1597
+ if (wholeFileOperation.kind === "delete") {
1598
+ enforcePlanModeWrite(session, sectionPath, { op: "delete" });
1599
+ await assertEditableFile(absolutePath, sectionPath);
1600
+ return;
1601
+ }
1602
+
1603
+ const destinationPath = wholeFileOperation.destination;
1604
+ if (destinationPath.endsWith(".ipynb")) {
1605
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1606
+ }
1607
+ enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
1608
+ const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
1609
+ if (absoluteDestinationPath === absolutePath) {
1610
+ throw new Error("rename path is the same as source path");
1611
+ }
1612
+ await assertEditableFile(absolutePath, sectionPath);
1613
+ return;
1614
+ }
1615
+
1616
+ const { edits } = parseAtomWithWarnings(diff);
1617
+ if (edits.length === 0 && diff.trim().length > 0) {
1618
+ throw new Error(formatNoAtomEditDiagnostic(sectionPath, diff));
1619
+ }
1620
+
1621
+ enforcePlanModeWrite(session, sectionPath, { op: "update" });
1622
+ if (sectionPath.endsWith(".ipynb") && edits.length > 0) {
1623
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1624
+ }
1625
+
1626
+ const absolutePath = resolvePlanPath(session, sectionPath);
1627
+ const source = await readAtomFile(absolutePath);
1628
+ if (!source.exists && hasAnchorScopedEdit(edits)) {
1629
+ throw new Error(`File not found: ${sectionPath}`);
1630
+ }
1631
+ if (source.exists) {
1632
+ assertEditableFileContent(source.rawContent, sectionPath);
1633
+ }
1634
+
1635
+ const { text } = stripBom(source.rawContent);
1636
+ const originalNormalized = normalizeToLF(text);
1637
+ const result = applyAtomEdits(originalNormalized, edits);
1638
+ if (originalNormalized === result.lines && (result.noopEdits?.length ?? 0) === 0) {
1639
+ throw new Error(formatNoChangeDiagnostic(sectionPath, result));
1640
+ }
1641
+ }
1642
+
1222
1643
  async function executeAtomSection(
1223
1644
  options: ExecuteAtomSingleOptions & AtomInputSection,
1224
1645
  ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
@@ -1227,7 +1648,7 @@ async function executeAtomSection(
1227
1648
  return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
1228
1649
  }
1229
1650
 
1230
- const edits = parseAtom(diff);
1651
+ const { edits, warnings: parseWarnings } = parseAtomWithWarnings(diff);
1231
1652
  if (edits.length === 0 && diff.trim().length > 0) {
1232
1653
  throw new Error(formatNoAtomEditDiagnostic(path, diff));
1233
1654
  }
@@ -1253,7 +1674,18 @@ async function executeAtomSection(
1253
1674
  const originalNormalized = normalizeToLF(text);
1254
1675
  const result = applyAtomEdits(originalNormalized, edits);
1255
1676
  if (originalNormalized === result.lines) {
1256
- throw new Error(formatNoChangeDiagnostic(path, result));
1677
+ const allNoop = (result.noopEdits?.length ?? 0) > 0;
1678
+ if (!allNoop) {
1679
+ throw new Error(formatNoChangeDiagnostic(path, result));
1680
+ }
1681
+ // Every edit was a no-op (TEXT identical to the anchored line). Returning
1682
+ // success here breaks retry loops where models hammer the same `Lid=TEXT`
1683
+ // when TEXT happens to already match. The response makes the no-op
1684
+ // explicit so the model knows nothing changed and to move on.
1685
+ return {
1686
+ content: [{ type: "text", text: formatNoChangeDiagnostic(path, result) }],
1687
+ details: { diff: "", op: "update", meta: outputMeta().get() },
1688
+ };
1257
1689
  }
1258
1690
 
1259
1691
  const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
@@ -1272,7 +1704,8 @@ async function executeAtomSection(
1272
1704
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1273
1705
  .get();
1274
1706
  const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1275
- const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
1707
+ const allWarnings = [...parseWarnings, ...(result.warnings ?? [])];
1708
+ const warningsBlock = allWarnings.length > 0 ? `\n\nWarnings:\n${allWarnings.join("\n")}` : "";
1276
1709
  const previewBlock = preview.preview ? `\n${preview.preview}` : "";
1277
1710
  const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
1278
1711
 
@@ -1302,6 +1735,10 @@ export async function executeAtomSingle(
1302
1735
  return executeAtomSection({ ...options, ...section });
1303
1736
  }
1304
1737
 
1738
+ for (const section of sections) {
1739
+ await preflightAtomSection({ ...options, ...section });
1740
+ }
1741
+
1305
1742
  const results = [];
1306
1743
  for (const section of sections) {
1307
1744
  results.push({