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

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 (68) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/sdk/README.md +1 -1
  4. package/package.json +7 -7
  5. package/src/config/prompt-templates.ts +103 -8
  6. package/src/config/settings-schema.ts +14 -13
  7. package/src/config/settings.ts +1 -1
  8. package/src/cursor.ts +4 -4
  9. package/src/edit/index.ts +111 -109
  10. package/src/edit/line-hash.ts +33 -3
  11. package/src/edit/modes/apply-patch.ts +6 -4
  12. package/src/edit/modes/atom.lark +27 -0
  13. package/src/edit/modes/atom.ts +1039 -841
  14. package/src/edit/modes/hashline.ts +9 -10
  15. package/src/edit/modes/patch.ts +23 -19
  16. package/src/edit/modes/replace.ts +19 -15
  17. package/src/edit/renderer.ts +65 -8
  18. package/src/edit/streaming.ts +47 -77
  19. package/src/extensibility/extensions/types.ts +11 -11
  20. package/src/extensibility/hooks/types.ts +6 -6
  21. package/src/lsp/edits.ts +8 -5
  22. package/src/lsp/index.ts +4 -4
  23. package/src/lsp/utils.ts +7 -7
  24. package/src/mcp/discoverable-tool-metadata.ts +1 -1
  25. package/src/mcp/manager.ts +3 -3
  26. package/src/mcp/tool-bridge.ts +4 -4
  27. package/src/memories/index.ts +1 -1
  28. package/src/modes/acp/acp-event-mapper.ts +1 -1
  29. package/src/modes/components/session-observer-overlay.ts +1 -1
  30. package/src/modes/components/settings-defs.ts +3 -3
  31. package/src/modes/components/tree-selector.ts +2 -2
  32. package/src/modes/utils/ui-helpers.ts +31 -7
  33. package/src/prompts/agents/explore.md +1 -1
  34. package/src/prompts/agents/librarian.md +2 -2
  35. package/src/prompts/agents/plan.md +2 -2
  36. package/src/prompts/agents/reviewer.md +1 -1
  37. package/src/prompts/agents/task.md +2 -2
  38. package/src/prompts/system/plan-mode-active.md +1 -1
  39. package/src/prompts/system/system-prompt.md +116 -60
  40. package/src/prompts/tools/apply-patch.md +0 -2
  41. package/src/prompts/tools/atom.md +81 -63
  42. package/src/prompts/tools/bash.md +7 -4
  43. package/src/prompts/tools/checkpoint.md +1 -1
  44. package/src/prompts/tools/find.md +6 -1
  45. package/src/prompts/tools/hashline.md +10 -11
  46. package/src/prompts/tools/patch.md +13 -13
  47. package/src/prompts/tools/read.md +4 -4
  48. package/src/prompts/tools/replace.md +3 -3
  49. package/src/prompts/tools/{grep.md → search.md} +4 -4
  50. package/src/sdk.ts +19 -9
  51. package/src/session/agent-session.ts +65 -0
  52. package/src/system-prompt.ts +15 -5
  53. package/src/task/executor.ts +5 -0
  54. package/src/task/index.ts +10 -1
  55. package/src/tools/ast-edit.ts +4 -6
  56. package/src/tools/ast-grep.ts +4 -6
  57. package/src/tools/bash.ts +1 -1
  58. package/src/tools/file-recorder.ts +6 -6
  59. package/src/tools/find.ts +11 -13
  60. package/src/tools/index.ts +7 -7
  61. package/src/tools/path-utils.ts +31 -4
  62. package/src/tools/read.ts +12 -6
  63. package/src/tools/renderers.ts +2 -2
  64. package/src/tools/{grep.ts → search.ts} +32 -40
  65. package/src/tools/write.ts +8 -4
  66. package/src/web/search/index.ts +1 -1
  67. package/src/edit/block.ts +0 -308
  68. package/src/edit/indent.ts +0 -150
@@ -1,576 +1,547 @@
1
1
  /**
2
+ * Atom edit mode.
2
3
  *
3
- * Flat locator + verb edit mode backed by hashline anchors. Each entry carries
4
- * one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`, `sed`).
5
- * The runtime resolves those verbs into internal anchor-scoped edits and still
6
- * reuses hashline's staleness scheme (`computeLineHash`) verbatim.
4
+ * Single-string compact wire format. Each file section starts with `---path`;
5
+ * each following line is one statement:
7
6
  *
8
- * External shapes (one entry):
9
- * { path, loc: "5th", splice: ["..."] } // line replace
10
- * { path, loc: "(5th)", splice: ["..."] } // block body replace
11
- * { path, loc: "[5th]", splice: ["..."] } // whole node replace
12
- * { path, loc: "[5th", splice: ["..."] } // anchor (incl) → closer-1
13
- * { path, loc: "5th]", splice: ["..."] } // opener+1 → anchor (incl)
14
- * { path, loc: "5th", pre: [...], splice: [...], post: [...] } // line verbs combinable
15
- * { path, loc: "$", pre: [...] | post: [...] | sed: {...} } // file-scoped
16
- *
17
- * `splice: []` deletes; `splice: [""]` replaces with a single blank line. These
18
- * apply uniformly to single-line and bracketed (region) locators.
19
- *
20
- * Bracket forms in `loc` are reserved for `splice` (region replacement). `pre`,
21
- * `post`, and `sed` reject bracketed locators — they are line-only.
22
- *
23
- * For deleting or moving files, the agent should use bash.
7
+ * @Lid move cursor to just after the anchored line
8
+ * Lid=TEXT set the anchored line to TEXT and move cursor after it
9
+ * -Lid delete the anchored line and move cursor to its slot
10
+ * +TEXT insert TEXT at the cursor
11
+ * $ move cursor to beginning of file
12
+ * ^ move cursor to end of file
24
13
  */
25
14
 
15
+ import * as fs from "node:fs/promises";
16
+ import * as path from "node:path";
26
17
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
18
+ import { isEnoent } from "@oh-my-pi/pi-utils";
27
19
  import { type Static, Type } from "@sinclair/typebox";
28
20
  import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
29
21
  import type { ToolSession } from "../../tools";
30
- import { assertEditableFileContent } from "../../tools/auto-generated-guard";
31
- import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
22
+ import { assertEditableFile, assertEditableFileContent } from "../../tools/auto-generated-guard";
23
+ import {
24
+ invalidateFsScanAfterDelete,
25
+ invalidateFsScanAfterRename,
26
+ invalidateFsScanAfterWrite,
27
+ } from "../../tools/fs-cache-invalidation";
32
28
  import { outputMeta } from "../../tools/output-meta";
33
29
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
34
- import { checkBodyBraceBalance, type DelimiterKind, findEnclosingBlock } from "../block";
35
30
  import { generateDiffString } from "../diff";
36
- import { applyIndent, detectIndentStyle, stripCommonIndent } from "../indent";
37
- import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
31
+ import { computeLineHash } from "../line-hash";
38
32
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
39
33
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
40
34
  import {
41
35
  ANCHOR_REBASE_WINDOW,
42
36
  type Anchor,
43
37
  buildCompactHashlineDiffPreview,
44
- formatFullAnchorRequirement,
45
38
  HashlineMismatchError,
46
39
  type HashMismatch,
47
- hashlineParseText,
48
- parseTag,
49
40
  tryRebaseAnchor,
50
41
  } from "./hashline";
51
42
 
52
43
  // ═══════════════════════════════════════════════════════════════════════════
53
44
  // Schema
54
45
  // ═══════════════════════════════════════════════════════════════════════════
55
- const textSchema = Type.Array(Type.String());
56
46
 
57
- /**
58
- * Flat entry shape with shared locator fields and verb-specific payloads.
59
- * The runtime validator (`resolveAtomToolEdit`) enforces legal locator/verb
60
- * combinations. Keeping the schema flat reduces tool-definition size and gives
61
- * weaker models fewer branching shapes to sample from.
62
- */
63
- export const atomEditSchema = Type.Object(
64
- {
65
- loc: Type.String({
66
- description: "edit location",
67
- examples: ["1ab", "$", "src/foo.ts:1ab"],
68
- }),
69
- splice: Type.Optional(textSchema),
70
- pre: Type.Optional(textSchema),
71
- post: Type.Optional(textSchema),
72
- sed: Type.Optional(
73
- Type.Object(
74
- {
75
- pat: Type.String({ description: "regex" }),
76
- rep: Type.String({ description: "expression to replace with" }),
77
- g: Type.Optional(Type.Boolean({ description: "global flag", default: false })),
78
- },
79
- {
80
- additionalProperties: false,
81
- },
82
- ),
83
- ),
84
- },
85
- { additionalProperties: false },
86
- );
87
-
88
- export const atomEditParamsSchema = Type.Object(
89
- {
90
- path: Type.Optional(Type.String({ description: "default file path for edits" })),
91
- edits: Type.Array(atomEditSchema, { description: "edit ops" }),
92
- },
93
- { additionalProperties: false },
94
- );
95
-
96
- export type AtomToolEdit = Static<typeof atomEditSchema>;
47
+ export const atomEditParamsSchema = Type.Object({ input: Type.String() });
48
+
97
49
  export type AtomParams = Static<typeof atomEditParamsSchema>;
98
50
 
99
51
  // ═══════════════════════════════════════════════════════════════════════════
100
- // Internal resolved op shapes
52
+ // Parser
101
53
  // ═══════════════════════════════════════════════════════════════════════════
102
54
 
103
- export type AtomEdit =
104
- | { op: "splice"; pos: Anchor; lines: string[] }
105
- | { op: "pre"; pos: Anchor; lines: string[] }
106
- | { op: "post"; pos: Anchor; lines: string[] }
107
- | { op: "del"; pos: Anchor }
108
- | { op: "append_file"; lines: string[] }
109
- | { op: "prepend_file"; lines: string[] }
110
- | { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
111
- | { op: "sed_file"; spec: SedSpec; expression: string }
112
- | { op: "splice_block"; pos: Anchor; spec: SpliceBlockSpec; bracket: BracketShape };
113
-
114
- export interface SedSpec {
115
- pattern: string;
116
- replacement: string;
117
- global: boolean;
118
- }
55
+ // Permissive: any 2 lowercase letters. Invalid hashes flow through to a
56
+ // HashlineMismatchError downstream, matching the other hashline-backed modes.
57
+ const LID_RE = /^([1-9]\d*)([a-z]{2})/;
58
+ const LID_EXACT_RE = /^([1-9]\d*)([a-z]{2})$/;
119
59
 
120
- export interface SpliceBlockSpec {
121
- body: string[];
122
- kind: DelimiterKind;
60
+ interface ParsedAnchor {
61
+ line: number;
62
+ hash: string;
123
63
  }
124
64
 
125
- type BracketShape = "none" | "body" | "node" | "left_incl" | "left_excl" | "right_incl" | "right_excl";
126
-
127
- // File-extension lookup for the block delimiter family used when `loc`
128
- // has bracket forms. Most languages are brace-family; lisp-family uses `(`.
129
- // Anything not listed defaults to `{` (covers the long tail of brace-style
130
- // languages without enumerating every extension).
131
- const LISP_EXTENSIONS = new Set(["clj", "cljs", "cljc", "edn", "lisp", "lsp", "el", "scm", "ss", "rkt", "fnl"]);
132
-
133
- function fileExtension(path: string | undefined): string | undefined {
134
- if (!path) return undefined;
135
- const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
136
- const base = slash >= 0 ? path.slice(slash + 1) : path;
137
- const dot = base.lastIndexOf(".");
138
- if (dot <= 0) return undefined;
139
- return base.slice(dot + 1).toLowerCase();
140
- }
65
+ type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op: "delete" };
66
+
67
+ type AnchorStmt =
68
+ | { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
69
+ | { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
70
+ | { kind: "bof"; lineNum: number }
71
+ | { kind: "eof"; lineNum: number };
72
+
73
+ type InsertStmt = {
74
+ kind: "insert";
75
+ text: string;
76
+ lineNum: number;
77
+ };
78
+
79
+ type DiffishAddStmt = {
80
+ kind: "diffish_add";
81
+ anchor: ParsedAnchor;
82
+ text: string;
83
+ lineNum: number;
84
+ };
85
+
86
+ type DeleteWithOldStmt = {
87
+ kind: "delete_with_old";
88
+ anchor: ParsedAnchor;
89
+ old: string;
90
+ lineNum: number;
91
+ };
92
+
93
+ type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
94
+
95
+ type AtomCursor = { kind: "bof" } | { kind: "eof" } | { kind: "anchor"; anchor: Anchor };
141
96
 
142
- function resolveBlockDelimiterForPath(path: string | undefined): DelimiterKind {
143
- const ext = fileExtension(path);
144
- if (ext && LISP_EXTENSIONS.has(ext)) return "(";
145
- return "{";
97
+ export type AtomEdit =
98
+ | { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
99
+ | { kind: "set"; anchor: Anchor; text: string; lineNum: number; index: number; allowOldNewRepair: boolean }
100
+ | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
101
+
102
+ interface AtomApplyResult {
103
+ lines: string;
104
+ firstChangedLine?: number;
105
+ warnings?: string[];
106
+ noopEdits?: AtomNoopEdit[];
146
107
  }
147
108
 
148
- // ═══════════════════════════════════════════════════════════════════════════
149
- // Param guards
150
- // ═══════════════════════════════════════════════════════════════════════════
109
+ interface AtomNoopEdit {
110
+ editIndex: number;
111
+ loc: string;
112
+ reason: string;
113
+ current: string;
114
+ }
151
115
 
152
- const ATOM_VERB_KEYS = ["splice", "pre", "post", "sed"] as const;
153
- type AtomOptionalKey = "loc" | (typeof ATOM_VERB_KEYS)[number];
154
- const ATOM_OPTIONAL_KEYS = ["loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
155
-
156
- // Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
157
- // optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
158
- // captured as a content hint for anchor disambiguation.
159
- const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
160
-
161
- // Splits `path:loc` references where the right side starts with a valid anchor
162
- // (single `\d+<bigram>` or `<anchor>-<anchor>` range, optionally followed by a
163
- // content suffix using `|` or `:`). The non-greedy `(.+?)` picks the leftmost
164
- // colon whose RHS is a real anchor, so colons inside the loc's content suffix
165
- // (TS type annotations, etc.) don't break the split. Drive-letter prefixes like
166
- // `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
167
- // fails the anchor pattern.
168
- const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
169
- const PATH_LOC_SPLIT_RE = new RegExp(
170
- `^(.+?):([\\[(]?${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?[\\])]?)$`,
171
- );
172
-
173
- function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
174
- let next: Record<string, unknown> | undefined;
175
- const fields = edit as Record<string, unknown>;
176
- for (const key of ATOM_OPTIONAL_KEYS) {
177
- if (fields[key] !== null) continue;
178
- next ??= { ...fields };
179
- delete next[key];
180
- }
181
- return (next ?? fields) as AtomToolEdit;
116
+ interface IndexedAnchorEdit {
117
+ edit: Extract<AtomEdit, { kind: "insert" | "set" | "delete" }>;
118
+ idx: number;
182
119
  }
183
120
 
184
- type ParsedAtomLoc = { kind: "anchor"; pos: Anchor; bracket: BracketShape } | { kind: "file" };
121
+ function cloneCursor(cursor: AtomCursor): AtomCursor {
122
+ if (cursor.kind !== "anchor") return cursor;
123
+ return { kind: "anchor", anchor: { ...cursor.anchor } };
124
+ }
185
125
 
186
- // ═══════════════════════════════════════════════════════════════════════════
187
- // Resolution
188
- // ═══════════════════════════════════════════════════════════════════════════
126
+ function parseLidStmt(body: string, lineNum: number): AnchorStmt | null {
127
+ const m = LID_RE.exec(body);
128
+ if (!m) return null;
189
129
 
190
- /**
191
- * Parse an anchor reference like `"5th"`.
192
- *
193
- * Tolerant: on a malformed reference we still try to extract a 1-indexed line
194
- * number from the leading digits so the validator can surface the *correct*
195
- * `LINEHASH|content` for the user. The bogus hash is preserved in the returned
196
- * anchor so the validator emits a content-rich mismatch error.
197
- *
198
- * If we cannot recover even a line number, throw a usage-style error with the
199
- * raw reference quoted.
200
- */
201
- function parseAnchor(raw: string, opName: string): Anchor {
202
- if (typeof raw !== "string" || raw.length === 0) {
203
- throw new Error(`${opName} requires ${formatFullAnchorRequirement()}.`);
204
- }
205
- try {
206
- return parseTag(raw);
207
- } catch {
208
- const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
209
- if (lineMatch) {
210
- const line = Number.parseInt(lineMatch[1], 10);
211
- if (line >= 1) {
212
- // Sentinel hash that will never match a real line, forcing the validator
213
- // to report a mismatch with the actual hash + line content.
214
- return { line, hash: "??" };
215
- }
216
- }
217
- throw new Error(
218
- `${opName} requires ${formatFullAnchorRequirement(raw)} Could not find a line number in the anchor.`,
219
- );
130
+ const ln = Number.parseInt(m[1], 10);
131
+ const hash = m[2];
132
+ const rest = body.slice(m[0].length);
133
+ const anchor = { line: ln, hash };
134
+ if (rest.length === 0) {
135
+ return { kind: "bare_anchor", anchor, lineNum };
220
136
  }
137
+
138
+ 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
+ };
221
146
  }
222
147
 
223
- function tryParseAtomTag(raw: string): Anchor | undefined {
224
- try {
225
- return parseTag(raw);
226
- } catch {
227
- return undefined;
148
+ function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
149
+ const trimmedBody = body.trimStart();
150
+ const exact = LID_EXACT_RE.exec(trimmedBody);
151
+ if (exact) {
152
+ const ln = Number.parseInt(exact[1], 10);
153
+ return [{ kind: "anchor_op", anchor: { line: ln, hash: exact[2] }, op: { op: "delete" }, lineNum }];
228
154
  }
229
- }
230
155
 
231
- function resolveAtomEntryPath(
232
- edit: AtomToolEdit,
233
- topLevelPath: string | undefined,
234
- editIndex: number,
235
- ): AtomToolEdit & { path: string } {
236
- const entry = stripNullAtomFields(edit);
237
- let loc = entry.loc;
238
- let pathOverride: string | undefined;
239
- if (typeof loc === "string") {
240
- const split = loc.match(PATH_LOC_SPLIT_RE);
241
- if (split) {
242
- pathOverride = split[1];
243
- loc = split[2]!;
244
- }
156
+ const m = LID_RE.exec(trimmedBody);
157
+ if (m && (trimmedBody[m[0].length] === "|" || trimmedBody[m[0].length] === "=")) {
158
+ const ln = Number.parseInt(m[1], 10);
159
+ const old = trimmedBody.slice(m[0].length + 1);
160
+ return [{ kind: "delete_with_old", anchor: { line: ln, hash: m[2] }, old, lineNum }];
245
161
  }
246
- const path = pathOverride || topLevelPath;
247
- if (!path) {
248
- throw new Error(
249
- `Edit ${editIndex}: missing path. Provide a top-level path or prefix loc with a file path (for example "a.ts:160sr").`,
250
- );
162
+ if (m && trimmedBody[m[0].length] === " ") {
163
+ const ln = Number.parseInt(m[1], 10);
164
+ const text = trimmedBody.slice(m[0].length + 1);
165
+ return [
166
+ { kind: "anchor_op", anchor: { line: ln, hash: m[2] }, op: { op: "delete" }, lineNum },
167
+ { kind: "insert", text, lineNum },
168
+ ];
251
169
  }
252
- return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
253
- }
254
170
 
255
- export function resolveAtomEntryPaths(
256
- edits: readonly AtomToolEdit[],
257
- topLevelPath: string | undefined,
258
- ): (AtomToolEdit & { path: string })[] {
259
- return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i));
171
+ return null;
260
172
  }
261
173
 
262
- function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
263
- const trimmed = raw.trim();
264
- if (trimmed === "$") return { kind: "file" };
174
+ function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
175
+ const text = line.trimStart();
176
+ const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
177
+ const withoutMove = withoutLegacyMove.startsWith("@") ? withoutLegacyMove.slice(1) : withoutLegacyMove;
178
+ const withoutDelete = withoutMove.startsWith("-") ? withoutMove.slice(1).trimStart() : withoutMove;
265
179
 
266
- const leading = trimmed[0];
267
- const trailing = trimmed[trimmed.length - 1];
268
- const hasLeading = leading === "[" || leading === "(";
269
- const hasTrailing = trailing === "]" || trailing === ")";
270
- if ((leading === "(" && trailing === "]") || (leading === "[" && trailing === ")")) {
180
+ const partial = /^([a-z]{2})(?=[ \t]*[=|])/.exec(withoutDelete);
181
+ if (partial) {
271
182
  throw new Error(
272
- `Edit ${editIndex}: mixed bracket inclusivity in loc is ambiguous; use [anchor, (anchor, anchor], anchor), [anchor], or a bare anchor.`,
183
+ `Diff line ${lineNum}: \`${partial[1]}\` is not a full Lid. Use the full Lid from read output, e.g. \`119${partial[1]}\`.`,
273
184
  );
274
185
  }
275
186
 
276
- let inner = trimmed;
277
- if (hasLeading) inner = inner.slice(1);
278
- if (hasTrailing) inner = inner.slice(0, -1);
279
-
280
- // Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
281
- // loc (e.g. line content like `i--`) should not trigger the range error.
282
- const dash = inner.indexOf("-");
283
- if (dash > 0) {
284
- const left = inner.slice(0, dash);
285
- const right = inner.slice(dash + 1);
286
- if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
287
- throw new Error(
288
- `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
289
- );
290
- }
291
- }
292
- const pos = parseAnchor(inner, "loc");
293
- // Capture an optional content suffix after the anchor: `82zu| for (...)`.
294
- // The suffix acts as a hint for anchor disambiguation when the model's hash
295
- // is wrong but the content reveals the intended line.
296
- const hint = extractAnchorContentHint(inner);
297
- if (hint !== undefined) {
298
- pos.contentHint = hint;
187
+ const missing = /^([1-9]\d*)(?=[ \t]*[=|]|$)/.exec(withoutDelete);
188
+ if (missing) {
189
+ const prefix = text.startsWith("@@ ") ? `@@ ${missing[1]}` : missing[1];
190
+ throw new Error(
191
+ `Diff line ${lineNum}: \`${prefix}\` is missing the two-letter Lid suffix. Use the full Lid from read output, e.g. \`${prefix.startsWith("@@ ") ? "@@ " : ""}${missing[1]}ab\`.`,
192
+ );
299
193
  }
300
194
 
301
- let bracket: BracketShape = "none";
302
- if (leading === "[" && trailing === "]") bracket = "node";
303
- else if (leading === "[") bracket = "left_incl";
304
- else if (leading === "(" && trailing === ")") bracket = "body";
305
- else if (leading === "(") bracket = "left_excl";
306
- else if (trailing === "]") bracket = "right_incl";
307
- else if (trailing === ")") bracket = "right_excl";
308
- return { kind: "anchor", pos, bracket };
195
+ throw new Error(`Diff line ${lineNum}: cannot parse "${raw}".`);
309
196
  }
310
197
 
311
- function extractAnchorContentHint(raw: string): string | undefined {
312
- const match = raw.match(ANCHOR_PREFIX_RE);
313
- if (!match) return undefined;
314
- const rest = raw.slice(match[0].length);
315
- // Accept either the canonical `|` (HASHLINE_CONTENT_SEPARATOR) or the legacy
316
- // `:` separator. Models trained on older docs still emit `82zu: for (...)`.
317
- const sep = rest[0];
318
- if (sep !== HASHLINE_CONTENT_SEPARATOR && sep !== ":") return undefined;
319
- const hint = rest.slice(1);
320
- if (hint.trim().length === 0) return undefined;
321
- return hint;
322
- }
198
+ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
199
+ // Strip trailing CR (CRLF tolerance).
200
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
201
+ if (line.length === 0) return [];
202
+
203
+ // `+TEXT` inserts at the cursor. Everything after `+` is content. A
204
+ // `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
205
+ // emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
206
+ if (line[0] === "+") {
207
+ const body = line.slice(1);
208
+ const m = LID_RE.exec(body);
209
+ if (m) {
210
+ const sep = body[m[0].length];
211
+ if (sep === "=" || sep === "|") {
212
+ const ln = Number.parseInt(m[1], 10);
213
+ const text = body.slice(m[0].length + 1);
214
+ return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, text, lineNum }];
215
+ }
216
+ }
323
217
 
324
- function parseSedSpec(input: unknown, editIndex: number): SedSpec {
325
- if (input === null || typeof input !== "object" || Array.isArray(input)) {
326
- throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g?}.`);
218
+ // Auto-fix: `+@Lid` and `+-Lid` are almost always typos where the agent
219
+ // prefixed a cursor-move or delete op with `+`. Insert content matching
220
+ // these op shapes is essentially never legitimate in source code, and
221
+ // silently emitting them as literal text corrupts the file (e.g. a stray
222
+ // `@12ly` line in a C++ source). Split into the op + a blank `+` insert
223
+ // so the line count of the edit script is preserved for any downstream
224
+ // offset-sensitive logic.
225
+ if (body.length > 1 && (body[0] === "@" || body[0] === "-")) {
226
+ try {
227
+ const opStmts = parseDiffLine(body, lineNum);
228
+ const allOps = opStmts.length > 0 && opStmts.every(s => s.kind !== "insert" && s.kind !== "diffish_add");
229
+ if (allOps) {
230
+ return [...opStmts, { kind: "insert", text: "", lineNum }];
231
+ }
232
+ } catch {
233
+ // Body looked op-shaped but failed to parse; fall through to literal insert.
234
+ }
235
+ }
236
+ return [{ kind: "insert", text: body, lineNum }];
327
237
  }
328
- const obj = input as Record<string, unknown>;
329
- const pat = obj.pat;
330
- const rep = obj.rep;
331
- if (typeof pat !== "string" || pat.length === 0) {
332
- throw new Error(`Edit ${editIndex}: sed.pat must be a non-empty string.`);
238
+
239
+ // Canonical file-scope locators.
240
+ if (line === "$") return [{ kind: "bof", lineNum }];
241
+ if (line === "^") return [{ kind: "eof", lineNum }];
242
+
243
+ // `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
244
+ // historical `-Lid TEXT` delete-then-insert recovery.
245
+ if (line[0] === "-") {
246
+ const parsed = parseDeleteStmt(line.slice(1), lineNum);
247
+ if (parsed) return parsed;
248
+ throw new Error(`Diff line ${lineNum}: \`-\` must be followed by a Lid (e.g. \`-5xx\`). Got "${raw}".`);
333
249
  }
334
- if (pat.includes("\n")) {
335
- throw new Error(
336
- `Edit ${editIndex}: sed.pat must be a single line; contains a newline. Use \`splice\` to replace multiple lines, anchoring the first changed line and listing replacement lines in the array.`,
337
- );
250
+
251
+ // Legacy move prefix. Runtime accepts old locators and common slipped edit
252
+ // operations, while the grammar/prompt bias models to canonical syntax.
253
+ if (line.startsWith("@@ ")) {
254
+ const body = line.slice(3);
255
+ if (body === "BOF") return [{ kind: "bof", lineNum }];
256
+ if (body === "EOF") return [{ kind: "eof", lineNum }];
257
+
258
+ const deleteStmt = body.startsWith("-") ? parseDeleteStmt(body.slice(1), lineNum) : null;
259
+ if (deleteStmt) return deleteStmt;
260
+
261
+ const lidStmt = parseLidStmt(body, lineNum);
262
+ if (lidStmt) return [lidStmt];
263
+
264
+ throwMalformedLidDiagnostic(line, lineNum, raw);
338
265
  }
339
- if (typeof rep !== "string") {
340
- throw new Error(`Edit ${editIndex}: sed.rep must be a string.`);
266
+
267
+ // Canonical `@Lid` cursor moves. Leniently recover `@Lid=TEXT`,
268
+ // `@Lid|TEXT`, `@$`, and `@^`.
269
+ if (line[0] === "@") {
270
+ const body = line.slice(1);
271
+ if (body === "$") return [{ kind: "bof", lineNum }];
272
+ if (body === "^") return [{ kind: "eof", lineNum }];
273
+ const lidStmt = parseLidStmt(body, lineNum);
274
+ if (lidStmt) return [lidStmt];
275
+ throwMalformedLidDiagnostic(line, lineNum, raw);
341
276
  }
342
- const rawGlobal = obj.g;
343
- let global = false;
344
- if (rawGlobal !== undefined) {
345
- if (typeof rawGlobal !== "boolean") {
346
- throw new Error(`Edit ${editIndex}: sed.g must be a boolean when provided.`);
347
- }
348
- global = rawGlobal;
277
+
278
+ // `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
279
+ // A bare `Lid` is a cursor move.
280
+ const lidStmt = parseLidStmt(line, lineNum);
281
+ if (lidStmt) return [lidStmt];
282
+
283
+ if (/^[a-z]{2}(?=[ \t]*[=|])/.test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
284
+ throwMalformedLidDiagnostic(line, lineNum, raw);
349
285
  }
350
- return { pattern: pat, replacement: rep, global };
351
- }
352
286
 
353
- function formatSedExpression(spec: SedSpec): string {
354
- const obj: { pat: string; rep: string; g?: boolean } = {
355
- pat: spec.pattern,
356
- rep: spec.replacement,
357
- };
358
- // Only emit non-default flags so error messages stay compact (g defaults false).
359
- if (spec.global) obj.g = true;
360
- return JSON.stringify(obj);
287
+ // Reject any line that doesn't match a recognized op. Common case: a model
288
+ // emitted multi-line content after a `Lid=` or similar without `+` prefixes,
289
+ // or pasted raw context. Silently treating these as inserts corrupts files.
290
+ const preview = line.length > 80 ? `${line.slice(0, 80)}…` : line;
291
+ throw new Error(
292
+ `Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
293
+ );
361
294
  }
362
295
 
363
- function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
364
- const idx = currentLine.indexOf(spec.pattern);
365
- if (idx === -1) return { result: currentLine, matched: false };
366
- if (spec.global) {
367
- return { result: currentLine.split(spec.pattern).join(spec.replacement), matched: true };
296
+ function tokenizeDiff(diff: string): ParsedStmt[] {
297
+ const out: ParsedStmt[] = [];
298
+ const lines = diff.split("\n");
299
+ for (let i = 0; i < lines.length; i++) {
300
+ const lineNum = i + 1;
301
+ const stmts = parseDiffLine(lines[i], lineNum);
302
+ for (const stmt of stmts) {
303
+ // Last-set-wins: when the same anchor (line+hash) gets a second `set`,
304
+ // drop the earlier one. Models sometimes echo the OLD line and then the
305
+ // NEW line as replacements (e.g. `119yh|OLD` / `119yh|NEW`); the last is
306
+ // the intended value.
307
+ if (stmt.kind === "anchor_op" && stmt.op.op === "set") {
308
+ const key = `${stmt.anchor.line}:${stmt.anchor.hash}`;
309
+ for (let j = out.length - 1; j >= 0; j--) {
310
+ const prior = out[j];
311
+ if (
312
+ prior.kind === "anchor_op" &&
313
+ prior.op.op === "set" &&
314
+ `${prior.anchor.line}:${prior.anchor.hash}` === key
315
+ ) {
316
+ out.splice(j, 1);
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ out.push(stmt);
322
+ }
368
323
  }
369
- return {
370
- result: currentLine.slice(0, idx) + spec.replacement + currentLine.slice(idx + spec.pattern.length),
371
- matched: true,
372
- };
324
+ return normalizeHunks(out);
373
325
  }
374
326
 
375
- function applySedToLine(
376
- currentLine: string,
377
- spec: SedSpec,
378
- ): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
379
- let flags = "";
380
- if (spec.global) flags += "g";
381
- let re: RegExp | undefined;
382
- let compileError: string | undefined;
383
- try {
384
- re = new RegExp(spec.pattern, flags);
385
- } catch (e) {
386
- compileError = (e as Error).message;
387
- }
388
- if (re?.test(currentLine)) {
389
- re.lastIndex = 0;
390
- const probe = re.exec(currentLine);
391
- re.lastIndex = 0;
392
- // Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace` to
393
- // insert the replacement at the match position rather than substitute. When that
394
- // happens, fall through to the literal-substring fallback below — the model almost
395
- // always meant the pattern literally (`()` is the parens, `^` is a caret, etc.).
396
- if (!probe || probe[0].length > 0) {
397
- return { result: currentLine.replace(re, spec.replacement), matched: true };
327
+ // Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
328
+ // hunks and reorder so adds land at the FIRST delete's slot (block
329
+ // replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
330
+ // `set`. Standalone `+Lid|TEXT` and `+Lid|TEXT` referencing a Lid not in
331
+ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
332
+ const isDelete = (s: ParsedStmt): boolean =>
333
+ (s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
334
+ const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
335
+ const out: ParsedStmt[] = [];
336
+ let i = 0;
337
+ while (i < stmts.length) {
338
+ const stmt = stmts[i];
339
+ if (!isDelete(stmt)) {
340
+ if (stmt.kind === "diffish_add") {
341
+ const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
342
+ 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.`,
344
+ );
345
+ }
346
+ out.push(stmt);
347
+ i++;
348
+ continue;
349
+ }
350
+ const deletes: ParsedStmt[] = [];
351
+ while (i < stmts.length && isDelete(stmts[i])) {
352
+ deletes.push(stmts[i]);
353
+ i++;
354
+ }
355
+ const adds: ParsedStmt[] = [];
356
+ while (i < stmts.length && isAdd(stmts[i])) {
357
+ adds.push(stmts[i]);
358
+ i++;
359
+ }
360
+ const deletedLids = new Set(
361
+ deletes.map(d => {
362
+ const a = (d as { anchor: ParsedAnchor }).anchor;
363
+ return `${a.line}${a.hash}`;
364
+ }),
365
+ );
366
+ for (const add of adds) {
367
+ if (add.kind !== "diffish_add") continue;
368
+ const lid = `${add.anchor.line}${add.anchor.hash}`;
369
+ if (!deletedLids.has(lid)) {
370
+ 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}\`.`,
372
+ );
373
+ }
374
+ }
375
+ // Split the delete run into file-contiguous sub-runs. The block
376
+ // reorder (inserts land at the FIRST delete's slot) is meaningful only
377
+ // when the deletes describe a single contiguous file range. When the
378
+ // agent stacks deletes that target far-apart lines (e.g. `-186 -197
379
+ // -198 -199` to remove a debug line at 186 AND replace 197-199), each
380
+ // far-apart delete moves the cursor on its own; only the LAST
381
+ // contiguous group should attract the inserts.
382
+ const subruns = splitContiguousDeletes(deletes);
383
+ for (let r = 0; r < subruns.length - 1; r++) {
384
+ for (const d of subruns[r]) out.push(d);
385
+ }
386
+ const lastDeletes = subruns[subruns.length - 1];
387
+
388
+ // Single-line case: 1 delete in the last sub-run + 1 diffish_add same Lid → fuse to set.
389
+ if (lastDeletes.length === 1 && adds.length === 1 && adds[0].kind === "diffish_add") {
390
+ const dAnchor = (lastDeletes[0] as { anchor: ParsedAnchor }).anchor;
391
+ const a = adds[0];
392
+ if (a.anchor.line === dAnchor.line && a.anchor.hash === dAnchor.hash) {
393
+ out.push({
394
+ kind: "anchor_op",
395
+ anchor: a.anchor,
396
+ op: { op: "set", text: a.text, allowOldNewRepair: false },
397
+ lineNum: a.lineNum,
398
+ });
399
+ continue;
400
+ }
401
+ }
402
+ // Block: emit lastDeletes[0], then all inserts (which land at lastDeletes[0]'s slot
403
+ // because the cursor binds to lastDeletes[0] before the inserts), then the
404
+ // remaining lastDeletes.
405
+ out.push(lastDeletes[0]);
406
+ for (const add of adds) {
407
+ const text = add.kind === "insert" ? add.text : (add as DiffishAddStmt).text;
408
+ out.push({ kind: "insert", text, lineNum: add.lineNum });
409
+ }
410
+ for (let j = 1; j < lastDeletes.length; j++) {
411
+ out.push(lastDeletes[j]);
398
412
  }
399
413
  }
400
- // Fall back to literal substring match. Models frequently send sed patterns
401
- // containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
402
- // intend as literal code. Trying a literal match before reporting failure
403
- // recovers the obvious intent without changing semantics for patterns that
404
- // already match as regex.
405
- const literal = applyLiteralSed(currentLine, spec);
406
- if (literal.matched) {
407
- return { ...literal, literalFallback: true };
408
- }
409
- if (compileError !== undefined) {
410
- return { result: currentLine, matched: false, error: compileError };
411
- }
412
- return { result: currentLine, matched: false };
414
+ return out;
413
415
  }
414
416
 
415
- function classifyAtomEdit(edit: AtomToolEdit): string {
416
- const entry = stripNullAtomFields(edit);
417
- const verbs = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
418
- return verbs.length > 0 ? verbs.join("+") : "unknown";
417
+ function makeAnchor(anchor: ParsedAnchor): Anchor {
418
+ return { line: anchor.line, hash: anchor.hash };
419
419
  }
420
420
 
421
- function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0, path?: string): AtomEdit[] {
422
- const entry = stripNullAtomFields(edit);
423
- const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
424
- if (verbKeysPresent.length === 0) {
425
- throw new Error(
426
- `Edit ${editIndex}: missing verb. Each entry must include at least one of: ${ATOM_VERB_KEYS.join(", ")}.`,
427
- );
428
- }
429
- if (typeof entry.loc !== "string") {
430
- throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr" or "$".`);
421
+ function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
422
+ if (deletes.length === 0) return [];
423
+ const getLine = (s: ParsedStmt): number => {
424
+ if (s.kind === "anchor_op") return s.anchor.line;
425
+ if (s.kind === "delete_with_old") return s.anchor.line;
426
+ throw new Error("internal: splitContiguousDeletes received non-delete stmt");
427
+ };
428
+ const subruns: ParsedStmt[][] = [];
429
+ let current: ParsedStmt[] = [deletes[0]];
430
+ for (let i = 1; i < deletes.length; i++) {
431
+ if (getLine(deletes[i]) === getLine(deletes[i - 1]) + 1) {
432
+ current.push(deletes[i]);
433
+ } else {
434
+ subruns.push(current);
435
+ current = [deletes[i]];
436
+ }
431
437
  }
438
+ subruns.push(current);
439
+ return subruns;
440
+ }
441
+
442
+ // ═══════════════════════════════════════════════════════════════════════════
443
+ // Build cursor-program from ParsedStmt[]
444
+ // ═══════════════════════════════════════════════════════════════════════════
432
445
 
433
- const loc = parseLoc(entry.loc, editIndex);
434
- const resolved: AtomEdit[] = [];
446
+ export function parseAtom(diff: string): AtomEdit[] {
447
+ const edits: AtomEdit[] = [];
448
+ let cursor: AtomCursor = { kind: "eof" };
449
+ let index = 0;
435
450
 
436
- if (loc.kind === "file") {
437
- if (entry.splice !== undefined) {
438
- throw new Error(`Edit ${editIndex}: loc "$" supports pre, post, and sed (not splice).`);
439
- }
440
- if (entry.pre !== undefined) {
441
- resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
451
+ for (const stmt of tokenizeDiff(diff)) {
452
+ if (stmt.kind === "insert") {
453
+ edits.push({ kind: "insert", cursor: cloneCursor(cursor), text: stmt.text, lineNum: stmt.lineNum, index });
454
+ index++;
455
+ continue;
442
456
  }
443
- if (entry.post !== undefined) {
444
- resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
457
+
458
+ if (stmt.kind === "bof") {
459
+ cursor = { kind: "bof" };
460
+ continue;
445
461
  }
446
- if (entry.sed !== undefined) {
447
- const spec = parseSedSpec(entry.sed, editIndex);
448
- resolved.push({ op: "sed_file", spec, expression: formatSedExpression(spec) });
462
+ if (stmt.kind === "eof") {
463
+ cursor = { kind: "eof" };
464
+ continue;
449
465
  }
450
- return resolved;
451
- }
452
466
 
453
- if (loc.bracket !== "none") {
454
- // Bracketed locator: only `splice` is meaningful (region replacement).
455
- const hasInvalidVerb = entry.pre !== undefined || entry.post !== undefined || entry.sed !== undefined;
456
- if (hasInvalidVerb) {
457
- throw new Error(
458
- `Edit ${editIndex}: bracket forms in loc are splice-only; remove pre/post/sed or use a bare anchor.`,
459
- );
467
+ if (stmt.kind === "delete_with_old") {
468
+ const anchor = makeAnchor(stmt.anchor);
469
+ cursor = { kind: "anchor", anchor: { ...anchor } };
470
+ edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index, oldAssertion: stmt.old });
471
+ index++;
472
+ continue;
460
473
  }
461
- if (entry.splice === undefined) {
462
- throw new Error(
463
- `Edit ${editIndex}: bracket loc requires \`splice\`. Bare anchors are line-only; brackets address a region.`,
464
- );
474
+
475
+ if (stmt.kind === "diffish_add") {
476
+ throw new Error("Internal atom error: unresolved diff-ish add reached parseAtom.");
465
477
  }
466
- const kind = resolveBlockDelimiterForPath(path);
467
- const body = hashlineParseText(entry.splice);
468
- resolved.push({ op: "splice_block", pos: loc.pos, spec: { body, kind }, bracket: loc.bracket });
469
- return resolved;
470
- }
471
478
 
472
- if (entry.pre !== undefined) {
473
- resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
474
- }
475
- if (entry.splice !== undefined) {
476
- if (Array.isArray(entry.splice) && entry.splice.length === 0) {
477
- // Models often default `splice: []` alongside other verbs (notably `sed`).
478
- // Treating that combination as an explicit `del` produces a confusing
479
- // `Conflicting ops` error. When another mutating verb is present, drop
480
- // the empty `splice` instead of treating it as a deletion.
481
- if (entry.sed === undefined) {
482
- resolved.push({ op: "del", pos: loc.pos });
479
+ const anchor = makeAnchor(stmt.anchor);
480
+ cursor = { kind: "anchor", anchor: { ...anchor } };
481
+ if (stmt.kind === "bare_anchor") continue;
482
+
483
+ if (stmt.op.op === "set") {
484
+ if (stmt.op.text.includes("\r")) {
485
+ throw new Error(
486
+ `Diff line ${stmt.lineNum}: set value contains a carriage return; use a single-line value.`,
487
+ );
483
488
  }
484
- } else {
485
- resolved.push({ op: "splice", pos: loc.pos, lines: hashlineParseText(entry.splice) });
486
- }
487
- }
488
- if (entry.post !== undefined) {
489
- resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
490
- }
491
- if (entry.sed !== undefined) {
492
- const spliceIsExplicitReplacement = Array.isArray(entry.splice) && entry.splice.length > 0;
493
- // Models often duplicate intent by sending both an explicit `splice` and a
494
- // matching `sed`. The explicit replacement wins; the redundant `sed` would
495
- // otherwise trigger a confusing `Conflicting ops` rejection.
496
- if (!spliceIsExplicitReplacement) {
497
- const spec = parseSedSpec(entry.sed, editIndex);
498
- resolved.push({ op: "sed", pos: loc.pos, spec, expression: formatSedExpression(spec) });
489
+ edits.push({
490
+ kind: "set",
491
+ anchor,
492
+ text: stmt.op.text,
493
+ lineNum: stmt.lineNum,
494
+ index,
495
+ allowOldNewRepair: stmt.op.allowOldNewRepair,
496
+ });
497
+ index++;
498
+ continue;
499
499
  }
500
+
501
+ edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index });
502
+ index++;
500
503
  }
501
- return resolved;
504
+
505
+ return edits;
506
+ }
507
+
508
+ function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
509
+ const body = diff
510
+ .split("\n")
511
+ .map(line => (line.endsWith("\r") ? line.slice(0, -1) : line))
512
+ .filter(line => line.trim().length > 0)
513
+ .slice(0, 3)
514
+ .map(line => ` ${line}`)
515
+ .join("\n");
516
+ const preview = body.length > 0 ? `\nReceived only locator/context lines:\n${body}` : "";
517
+ return `Cursor moved but no mutation found. Add +TEXT to insert, -Lid to delete, or Lid=TEXT to replace.${preview}`;
502
518
  }
503
519
 
504
520
  // ═══════════════════════════════════════════════════════════════════════════
505
- // Validation
521
+ // Apply cursor-program
506
522
  // ═══════════════════════════════════════════════════════════════════════════
507
523
 
508
- function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
509
- switch (edit.op) {
510
- case "splice":
511
- case "pre":
512
- case "post":
513
- case "del":
514
- case "sed":
515
- case "splice_block":
516
- yield edit.pos;
517
- return;
518
- default:
519
- return;
520
- }
521
- }
522
-
523
- /**
524
- * Search for a line near `anchor.line` whose trimmed content equals the
525
- * anchor's content hint. Returns the closest match (preferring lines below the
526
- * requested anchor on ties) or `null` when no line matches. Strict equality on
527
- * trimmed content keeps this conservative \u2014 we only retarget when there is no
528
- * ambiguity about the model's intent.
529
- */
530
- function findLineByContentHint(anchor: Anchor, fileLines: string[]): number | null {
531
- const hint = anchor.contentHint?.trim();
532
- if (!hint) return null;
533
- const lo = Math.max(1, anchor.line - ANCHOR_REBASE_WINDOW);
534
- const hi = Math.min(fileLines.length, anchor.line + ANCHOR_REBASE_WINDOW);
535
- let best: { line: number; distance: number } | null = null;
536
- for (let line = lo; line <= hi; line++) {
537
- if (fileLines[line - 1].trim() !== hint) continue;
538
- const distance = Math.abs(line - anchor.line);
539
- if (best === null || distance < best.distance) {
540
- best = { line, distance };
541
- }
542
- }
543
- return best?.line ?? null;
524
+ function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
525
+ if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
526
+ if (edit.cursor.kind === "anchor") return [edit.cursor.anchor];
527
+ return [];
544
528
  }
545
529
 
546
530
  function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
547
531
  const mismatches: HashMismatch[] = [];
532
+ const rebasedAnchors = new Map<Anchor, HashMismatch>();
548
533
  for (const edit of edits) {
549
- for (const anchor of getAtomAnchors(edit)) {
534
+ for (const anchor of getAtomEditAnchors(edit)) {
550
535
  if (anchor.line < 1 || anchor.line > fileLines.length) {
551
536
  throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
552
537
  }
553
538
  const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
554
539
  if (actualHash === anchor.hash) continue;
555
- // When the model supplied a content hint after the anchor (e.g.
556
- // `82zu| for (...)`), prefer rebasing to the line that actually matches
557
- // that content. This avoids false positives from hash-only rebasing where
558
- // a coincidentally matching hash on a nearby line silently retargets the
559
- // edit to the wrong line.
560
- const hinted = findLineByContentHint(anchor, fileLines);
561
- if (hinted !== null) {
562
- const original = `${anchor.line}${anchor.hash}`;
563
- const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
564
- anchor.line = hinted;
565
- anchor.hash = hintedHash;
566
- warnings.push(
567
- `Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
568
- );
569
- continue;
570
- }
540
+
571
541
  const rebased = tryRebaseAnchor(anchor, fileLines);
572
542
  if (rebased !== null) {
573
543
  const original = `${anchor.line}${anchor.hash}`;
544
+ rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
574
545
  anchor.line = rebased;
575
546
  warnings.push(
576
547
  `Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
@@ -580,198 +551,217 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
580
551
  mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
581
552
  }
582
553
  }
554
+
555
+ // Detect post-rebase conflicts. If any conflicting anchor was rebased, surface
556
+ // the original hash mismatch instead — the rebase itself is what created the
557
+ // conflict, and the model needs to fix the stale anchor, not deduplicate.
558
+ const seenLines = new Map<number, Anchor>();
559
+ for (const edit of edits) {
560
+ if (edit.kind !== "set" && edit.kind !== "delete") continue;
561
+ const existing = seenLines.get(edit.anchor.line);
562
+ if (existing) {
563
+ const rebasedA = rebasedAnchors.get(edit.anchor);
564
+ const rebasedB = rebasedAnchors.get(existing);
565
+ if (rebasedA) mismatches.push(rebasedA);
566
+ else if (rebasedB) mismatches.push(rebasedB);
567
+ continue;
568
+ }
569
+ seenLines.set(edit.anchor.line, edit.anchor);
570
+ }
583
571
  return mismatches;
584
572
  }
585
573
 
586
- function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
587
- // For each anchor line, at most one mutating op (splice/del). Multiple `sed`
588
- // ops on the same line are allowed and applied sequentially. `pre`/`post`
589
- // (insert ops) may coexist with them — they don't mutate the anchor line.
574
+ function validateNoConflictingAtomMutations(edits: AtomEdit[]): void {
590
575
  const mutatingPerLine = new Map<number, string>();
591
576
  for (const edit of edits) {
592
- if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
593
- const existing = mutatingPerLine.get(edit.pos.line);
577
+ if (edit.kind !== "set" && edit.kind !== "delete") continue;
578
+ const existing = mutatingPerLine.get(edit.anchor.line);
594
579
  if (existing) {
595
- if (existing === "sed" && edit.op === "sed") continue;
596
580
  throw new Error(
597
- `Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
598
- `At most one of splice/del is allowed per anchor.`,
581
+ `Conflicting ops on anchor line ${edit.anchor.line}: \`${existing}\` and \`${edit.kind}\`. ` +
582
+ "At most one mutating op (set/delete) is allowed per anchor.",
599
583
  );
600
584
  }
601
- mutatingPerLine.set(edit.pos.line, edit.op);
585
+ mutatingPerLine.set(edit.anchor.line, edit.kind);
602
586
  }
603
587
  }
604
588
 
605
- // ═══════════════════════════════════════════════════════════════════════════
606
- // Apply
607
- // ═══════════════════════════════════════════════════════════════════════════
608
-
609
- export interface AtomNoopEdit {
610
- editIndex: number;
611
- loc: string;
612
- reason: string;
613
- current: string;
614
- }
615
-
616
- interface SpliceBlockApplyResult {
617
- text: string;
618
- firstChangedLine: number | undefined;
589
+ function repairAtomOldNewSetLine(currentLine: string, nextLine: string): string {
590
+ const marker = `${currentLine}|`;
591
+ if (!nextLine.startsWith(marker)) return nextLine;
592
+ const repaired = nextLine.slice(marker.length);
593
+ return repaired.length > 0 ? repaired : nextLine;
619
594
  }
620
595
 
621
- type SpliceBlockEdit = Extract<AtomEdit, { op: "splice_block" }>;
622
-
623
- function lineStartOffset(text: string, line: number): number {
624
- let currentLine = 1;
625
- let offset = 0;
626
- while (offset < text.length && currentLine < line) {
627
- if (text[offset] === "\n") currentLine++;
628
- offset++;
596
+ function insertAtStart(fileLines: string[], lines: string[]): void {
597
+ if (lines.length === 0) return;
598
+ if (fileLines.length === 1 && fileLines[0] === "") {
599
+ fileLines.splice(0, 1, ...lines);
600
+ return;
629
601
  }
630
- return offset;
602
+ fileLines.splice(0, 0, ...lines);
631
603
  }
632
604
 
633
- function lineEndOffset(text: string, line: number): number {
634
- let offset = lineStartOffset(text, line);
635
- while (offset < text.length && text[offset] !== "\n") offset++;
636
- return offset;
605
+ function insertAtEnd(fileLines: string[], lines: string[]): number | undefined {
606
+ if (lines.length === 0) return undefined;
607
+ if (fileLines.length === 1 && fileLines[0] === "") {
608
+ fileLines.splice(0, 1, ...lines);
609
+ return 1;
610
+ }
611
+ const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
612
+ const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
613
+ fileLines.splice(insertIdx, 0, ...lines);
614
+ return insertIdx + 1;
637
615
  }
638
616
 
639
- function lineEndIncludingNewlineOffset(text: string, line: number): number {
640
- const offset = lineEndOffset(text, line);
641
- return text[offset] === "\n" ? offset + 1 : offset;
617
+ function isSameFileCursor(a: AtomCursor, b: AtomCursor): boolean {
618
+ return a.kind === b.kind && a.kind !== "anchor";
642
619
  }
643
620
 
644
- function spliceBlockLocatorLabel(bracket: BracketShape): string {
645
- switch (bracket) {
646
- case "none":
647
- case "body":
648
- return "(anchor)";
649
- case "node":
650
- return "[anchor]";
651
- case "left_incl":
652
- return "[anchor";
653
- case "left_excl":
654
- return "(anchor";
655
- case "right_incl":
656
- return "anchor]";
657
- case "right_excl":
658
- return "anchor)";
621
+ function collectFileInsertRuns(
622
+ fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
623
+ ): Array<{ cursor: AtomCursor; lines: string[] }> {
624
+ const runs: Array<{ cursor: AtomCursor; lines: string[] }> = [];
625
+ for (const edit of fileInserts.sort((a, b) => a.index - b.index)) {
626
+ const prev = runs[runs.length - 1];
627
+ if (prev && isSameFileCursor(prev.cursor, edit.cursor)) {
628
+ prev.lines.push(edit.text);
629
+ continue;
630
+ }
631
+ runs.push({ cursor: edit.cursor, lines: [edit.text] });
659
632
  }
633
+ return runs;
660
634
  }
661
-
662
- function applySpliceBlockEdits(
663
- originalText: string,
664
- edits: SpliceBlockEdit[],
665
- warnings: string[],
666
- ): SpliceBlockApplyResult {
667
- // Sort by anchor line descending so applying earlier ops doesn't shift
668
- // later anchors. (Multiple splice_block ops within one call are assumed
669
- // non-overlapping; overlapping ranges are not supported.)
670
- const sorted = [...edits].sort((a, b) => b.pos.line - a.pos.line);
671
- const destStyle = detectIndentStyle(originalText);
672
- let text = originalText;
635
+ function applyFileCursorInserts(
636
+ fileLines: string[],
637
+ fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
638
+ ): number | undefined {
673
639
  let firstChangedLine: number | undefined;
640
+ const trackFirstChanged = (line: number) => {
641
+ if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
642
+ };
674
643
 
675
- for (const edit of sorted) {
676
- const kind: DelimiterKind = edit.spec.kind;
677
- const found = findEnclosingBlock(text, edit.pos.line, { kind, depth: 0 });
678
- if ("message" in found) {
679
- throw new Error(`splice at anchor ${edit.pos.line}: ${found.message}`);
644
+ for (const run of collectFileInsertRuns(fileInserts)) {
645
+ if (run.cursor.kind === "bof") {
646
+ insertAtStart(fileLines, run.lines);
647
+ trackFirstChanged(1);
648
+ continue;
680
649
  }
681
- const replacedLineCount = found.closeLine - found.openLine + 1;
682
- warnings.push(
683
- `splice locator ${spliceBlockLocatorLabel(edit.bracket)} replaced \`${kind}\` block at lines ${found.openLine}-${found.closeLine} ` +
684
- `(${replacedLineCount} lines, 1 of ${found.enclosingCount} enclosing \`${kind}\` blocks).`,
685
- );
686
- const balanceErr = checkBodyBraceBalance(edit.spec.body.join("\n"), kind);
687
- if (balanceErr) {
688
- throw new Error(`splice at anchor ${edit.pos.line}: ${balanceErr}`);
650
+ if (run.cursor.kind === "eof") {
651
+ const changedLine = insertAtEnd(fileLines, run.lines);
652
+ if (changedLine !== undefined) trackFirstChanged(changedLine);
689
653
  }
654
+ }
690
655
 
691
- const stripped = stripCommonIndent(edit.spec.body);
692
- const bodyPrefix = found.bodyLineIndent ?? `${found.openerLineIndent}${defaultIndentUnit(destStyle)}`;
656
+ return firstChangedLine;
657
+ }
693
658
 
694
- let replacementText: string;
695
- let replaceStart: number;
696
- let replaceEnd: number;
659
+ function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
660
+ 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.");
663
+ }
664
+ return edit.cursor.anchor;
665
+ }
697
666
 
698
- switch (edit.bracket) {
699
- case "node": {
700
- const indented = applyIndent(stripped, found.openerLineIndent, destStyle);
701
- replacementText = indented.join("\n");
702
- replaceStart = found.openLineStart;
703
- replaceEnd = found.closeOffsetExclusive;
704
- break;
705
- }
706
- case "left_incl":
707
- case "left_excl": {
708
- const indented = applyIndent(stripped, bodyPrefix, destStyle);
709
- replacementText = `${indented.join("\n")}\n${found.openerLineIndent}`;
710
- replaceStart =
711
- edit.bracket === "left_incl"
712
- ? lineStartOffset(text, edit.pos.line)
713
- : lineEndIncludingNewlineOffset(text, edit.pos.line);
714
- replaceEnd = found.bodyEnd;
715
- break;
716
- }
717
- case "right_incl":
718
- case "right_excl": {
719
- const indented = applyIndent(stripped, bodyPrefix, destStyle);
720
- replacementText = `\n${indented.join("\n")}\n`;
721
- replaceStart = found.bodyStart;
722
- replaceEnd =
723
- edit.bracket === "right_incl"
724
- ? lineEndIncludingNewlineOffset(text, edit.pos.line)
725
- : lineStartOffset(text, edit.pos.line);
726
- break;
727
- }
728
- case "none":
729
- case "body": {
730
- const goInline = found.sameLine && stripped.length === 1;
731
- if (goInline) {
732
- const single = stripped.length === 0 ? "" : stripped[0]!.trim();
733
- const pad = kind === "{" ? " " : "";
734
- replacementText = single.length > 0 ? `${pad}${single}${pad}` : pad;
735
- } else {
736
- const indented = applyIndent(stripped, bodyPrefix, destStyle);
737
- replacementText = `\n${indented.join("\n")}\n${found.openerLineIndent}`;
738
- }
739
- replaceStart = found.bodyStart;
740
- replaceEnd = found.bodyEnd;
741
- break;
667
+ // Heuristic: detect (and when safe, auto-fix) lines that became adjacent
668
+ // duplicates of themselves after the edit, when they were not adjacent
669
+ // duplicates before. This is the signature of a botched block rewrite that
670
+ // missed one delete on the front or back of the deletion range, leaving a
671
+ // stale copy of a line the agent already re-emitted (e.g. inserting a new
672
+ // closing `}` while the original `}` was never deleted, producing `}\n}`).
673
+ //
674
+ // Auto-fix is gated on bracket balance: we only remove the duplicate line if
675
+ // its removal restores the original file's `{}`/`()`/`[]` delta. That makes
676
+ // the fix safe in the common case (a stray closing brace shifts balance by
677
+ // one) and conservative when the duplicate is intentional (balance unchanged
678
+ // warning only). When two adjacent lines are textually identical, removing
679
+ // either yields the same content, so we don't have to decide which is "the
680
+ // stale copy" — we just remove one and verify balance restores.
681
+ function detectAndAutoFixDuplicates(
682
+ originalLines: string[],
683
+ finalLines: string[],
684
+ ): { fixed: string[] | null; warnings: string[] } {
685
+ const countAdjacent = (lines: string[]): Map<string, number> => {
686
+ const counts = new Map<string, number>();
687
+ for (let i = 0; i + 1 < lines.length; i++) {
688
+ if (lines[i] !== lines[i + 1]) continue;
689
+ if (lines[i].trim().length === 0) continue;
690
+ counts.set(lines[i], (counts.get(lines[i]) ?? 0) + 1);
691
+ }
692
+ return counts;
693
+ };
694
+
695
+ const computeBalance = (lines: string[]): { brace: number; paren: number; bracket: number } => {
696
+ let brace = 0;
697
+ let paren = 0;
698
+ let bracket = 0;
699
+ for (const line of lines) {
700
+ for (const ch of line) {
701
+ if (ch === "{") brace++;
702
+ else if (ch === "}") brace--;
703
+ else if (ch === "(") paren++;
704
+ else if (ch === ")") paren--;
705
+ else if (ch === "[") bracket++;
706
+ else if (ch === "]") bracket--;
742
707
  }
743
708
  }
709
+ return { brace, paren, bracket };
710
+ };
744
711
 
745
- const before = text.slice(0, replaceStart);
746
- const after = text.slice(replaceEnd);
747
- const newText = before + replacementText + after;
712
+ const balancesEqual = (
713
+ a: { brace: number; paren: number; bracket: number },
714
+ b: { brace: number; paren: number; bracket: number },
715
+ ): boolean => a.brace === b.brace && a.paren === b.paren && a.bracket === b.bracket;
716
+
717
+ const orig = countAdjacent(originalLines);
718
+ const fin = countAdjacent(finalLines);
719
+ const newDupPositions: number[] = [];
720
+ for (let i = 0; i + 1 < finalLines.length; i++) {
721
+ if (finalLines[i] !== finalLines[i + 1]) continue;
722
+ if (finalLines[i].trim().length === 0) continue;
723
+ const text = finalLines[i];
724
+ if ((fin.get(text) ?? 0) <= (orig.get(text) ?? 0)) continue;
725
+ newDupPositions.push(i);
726
+ }
748
727
 
749
- text = newText;
750
- if (firstChangedLine === undefined || found.openLine < firstChangedLine) {
751
- firstChangedLine = found.openLine;
728
+ if (newDupPositions.length === 0) return { fixed: null, warnings: [] };
729
+
730
+ const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
731
+
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
+ }
752
749
  }
753
750
  }
754
- return { text, firstChangedLine };
755
- }
756
751
 
757
- function defaultIndentUnit(style: { kind: "tab" | "space"; width: number }): string {
758
- return style.kind === "tab" ? "\t" : " ".repeat(Math.max(1, style.width));
752
+ const warnings = newDupPositions.slice(0, 3).map(pos => {
753
+ return `Suspicious duplicate: lines ${pos + 1} and ${pos + 2} are both ${formatPreview(finalLines[pos])}. The edit may have left a stale copy of a line you meant to replace — verify the result.`;
754
+ });
755
+ return { fixed: null, warnings };
759
756
  }
760
757
 
761
- export function applyAtomEdits(
762
- text: string,
763
- edits: AtomEdit[],
764
- ): {
765
- lines: string;
766
- firstChangedLine: number | undefined;
767
- warnings?: string[];
768
- noopEdits?: AtomNoopEdit[];
769
- } {
758
+ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult {
770
759
  if (edits.length === 0) {
771
760
  return { lines: text, firstChangedLine: undefined };
772
761
  }
773
762
 
774
763
  const fileLines = text.split("\n");
764
+ const originalLines = fileLines.slice();
775
765
  const warnings: string[] = [];
776
766
  let firstChangedLine: number | undefined;
777
767
  const noopEdits: AtomNoopEdit[] = [];
@@ -780,75 +770,31 @@ export function applyAtomEdits(
780
770
  if (mismatches.length > 0) {
781
771
  throw new HashlineMismatchError(mismatches, fileLines);
782
772
  }
783
- // When a `del` and a `sed`/`splice` target the same anchor (across separate edit
784
- // entries), the `del` is almost always a hallucinated cleanup the model added on top
785
- // of the real replacement. Drop the `del` silently so the replacement wins, matching
786
- // the in-entry handling for `splice: []` paired with `sed`.
787
- const replacedLines = new Set<number>();
788
- for (const e of edits) {
789
- if (e.op === "splice" || e.op === "sed") replacedLines.add(e.pos.line);
790
- }
791
- let effective = edits;
792
- if (replacedLines.size > 0) {
793
- effective = edits.filter(e => !(e.op === "del" && replacedLines.has(e.pos.line)));
794
- }
795
- validateNoConflictingAnchorOps(effective);
796
-
797
- // splice_block ops own their entire block range. To keep line numbers sane,
798
- // they cannot mix with other anchor-scoped ops in the same call. They may
799
- // coexist with each other (sorted by openLine descending so earlier ops
800
- // don't shift later anchors).
801
- const spliceBlockEdits = effective.filter(
802
- (e): e is Extract<AtomEdit, { op: "splice_block" }> => e.op === "splice_block",
803
- );
804
- if (spliceBlockEdits.length > 0) {
805
- const result = applySpliceBlockEdits(text, spliceBlockEdits, warnings);
806
- if (result.firstChangedLine !== undefined) {
807
- if (firstChangedLine === undefined || result.firstChangedLine < firstChangedLine) {
808
- firstChangedLine = result.firstChangedLine;
809
- }
810
- }
811
- // Continue pipeline against the post-splice_block text.
812
- fileLines.length = 0;
813
- for (const line of result.text.split("\n")) fileLines.push(line);
814
- effective = effective.filter(e => e.op !== "splice_block");
815
- }
773
+ validateNoConflictingAtomMutations(edits);
816
774
 
817
775
  const trackFirstChanged = (line: number) => {
818
- if (firstChangedLine === undefined || line < firstChangedLine) {
819
- firstChangedLine = line;
820
- }
776
+ if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
821
777
  };
822
778
 
823
- // Partition: anchor-scoped vs file-scoped. Preserve original order via the
824
- // captured idx so multiple pre/post on the same target are emitted in the order
825
- // the model produced them.
826
- type Indexed<T> = { edit: T; idx: number };
827
- type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" } | { op: "sed_file" }>;
828
- const anchorEdits: Indexed<AnchorEdit>[] = [];
829
- const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
830
- const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
831
- const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
832
- effective.forEach((edit, idx) => {
833
- if (edit.op === "append_file") appendEdits.push({ edit, idx });
834
- else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
835
- else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
836
- else anchorEdits.push({ edit, idx });
779
+ const anchorEdits: IndexedAnchorEdit[] = [];
780
+ const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
781
+ edits.forEach((edit, idx) => {
782
+ if (edit.kind === "insert" && edit.cursor.kind !== "anchor") {
783
+ fileInserts.push(edit);
784
+ return;
785
+ }
786
+ anchorEdits.push({ edit, idx });
837
787
  });
838
788
 
839
- // Group anchor edits by line so all ops on the same line are applied as a
840
- // single splice. This makes the per-anchor outcome independent of index
841
- // shifts caused by sibling ops (e.g. `post` paired with `del` on the same
842
- // anchor, or repeated `pre`/`post` inserts that previously reversed).
843
- const byLine = new Map<number, Indexed<AnchorEdit>[]>();
789
+ const byLine = new Map<number, IndexedAnchorEdit[]>();
844
790
  for (const entry of anchorEdits) {
845
- const line = entry.edit.pos.line;
846
- let bucket = byLine.get(line);
847
- if (!bucket) {
848
- bucket = [];
849
- byLine.set(line, bucket);
791
+ const line = getAnchorForAnchorEdit(entry.edit).line;
792
+ const bucket = byLine.get(line);
793
+ if (bucket) {
794
+ bucket.push(entry);
795
+ } else {
796
+ byLine.set(line, [entry]);
850
797
  }
851
- bucket.push(entry);
852
798
  }
853
799
 
854
800
  const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
@@ -862,237 +808,454 @@ export function applyAtomEdits(
862
808
  let replacement: string[] = [currentLine];
863
809
  let replacementSet = false;
864
810
  let anchorMutated = false;
865
- let anchorDeleted = false;
866
- const beforeLines: string[] = [];
867
811
  const afterLines: string[] = [];
868
812
 
869
813
  for (const { edit } of bucket) {
870
- switch (edit.op) {
871
- case "pre":
872
- beforeLines.push(...edit.lines);
873
- break;
874
- case "post":
875
- afterLines.push(...edit.lines);
876
- break;
877
- case "del":
878
- replacement = [];
879
- replacementSet = true;
880
- anchorDeleted = true;
814
+ switch (edit.kind) {
815
+ case "insert":
816
+ afterLines.push(edit.text);
881
817
  break;
882
- case "splice":
883
- replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
818
+ case "set":
819
+ replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
884
820
  replacementSet = true;
885
821
  anchorMutated = true;
886
822
  break;
887
- case "sed": {
888
- const input = replacementSet ? (replacement[0] ?? "") : currentLine;
889
- const { result, matched, error, literalFallback } = applySedToLine(input, edit.spec);
890
- if (error) {
891
- throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
892
- }
893
- if (!matched) {
823
+ case "delete":
824
+ if (edit.oldAssertion !== undefined && edit.oldAssertion !== currentLine) {
894
825
  throw new Error(
895
- `Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(input)}`,
896
- );
897
- }
898
- if (literalFallback) {
899
- warnings.push(
900
- `sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Escape regex metacharacters in the pattern to match as a regex.`,
826
+ `Diff line ${edit.lineNum}: \`-${edit.anchor.line}${edit.anchor.hash}\` asserts the deleted line is ${JSON.stringify(edit.oldAssertion)}, but the file has ${JSON.stringify(currentLine)}. Re-anchor and retry.`,
901
827
  );
902
828
  }
903
- replacement = [result];
829
+ replacement = [];
904
830
  replacementSet = true;
905
831
  anchorMutated = true;
906
832
  break;
907
- }
908
833
  }
909
834
  }
910
835
 
911
- const noOp = !replacementSet && beforeLines.length === 0 && afterLines.length === 0;
912
- if (noOp) continue;
913
-
914
- const originalLine = fileLines[idx];
915
836
  const replacementProducesNoChange =
916
- beforeLines.length === 0 &&
917
- afterLines.length === 0 &&
918
- replacement.length === 1 &&
919
- replacement[0] === originalLine;
837
+ afterLines.length === 0 && replacement.length === 1 && replacement[0] === currentLine;
920
838
  if (replacementProducesNoChange) {
921
839
  const firstEdit = bucket[0]?.edit;
922
- const loc = firstEdit ? `${firstEdit.pos.line}${firstEdit.pos.hash}` : `${line}`;
923
- const reason = "replacement is identical to the current line content";
840
+ const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
924
841
  noopEdits.push({
925
842
  editIndex: bucket[0]?.idx ?? 0,
926
- loc,
927
- reason,
928
- current: originalLine,
843
+ loc: anchor ? `${anchor.line}${anchor.hash}` : `${line}`,
844
+ reason:
845
+ firstEdit?.kind === "set"
846
+ ? "replacement is identical to the current line content; use `Lid=NEW_TEXT` and do not copy an unchanged read line"
847
+ : "replacement is identical to the current line content",
848
+ current: currentLine,
929
849
  });
930
850
  continue;
931
851
  }
932
852
 
933
- const combined = [...beforeLines, ...replacement, ...afterLines];
853
+ const combined = [...replacement, ...afterLines];
934
854
  fileLines.splice(idx, 1, ...combined);
935
-
936
- if (beforeLines.length > 0 || anchorMutated || anchorDeleted) {
855
+ if (anchorMutated) {
937
856
  trackFirstChanged(line);
938
857
  } else if (afterLines.length > 0) {
939
858
  trackFirstChanged(line + 1);
940
859
  }
860
+ if (!replacementSet && afterLines.length === 0) continue;
941
861
  }
942
862
 
943
- // Apply prepend_file ops in original order so the first one ends up at the
944
- // very top of the file.
945
- prependEdits.sort((a, b) => a.idx - b.idx);
946
- for (const { edit } of prependEdits) {
947
- if (edit.lines.length === 0) continue;
948
- if (fileLines.length === 1 && fileLines[0] === "") {
949
- fileLines.splice(0, 1, ...edit.lines);
950
- } else {
951
- // Insert in reverse cumulative order so later splices push earlier
952
- // content further down, preserving the original op order.
953
- fileLines.splice(0, 0, ...edit.lines);
954
- }
955
- trackFirstChanged(1);
863
+ const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
864
+ if (fileFirstChangedLine !== undefined) trackFirstChanged(fileFirstChangedLine);
865
+
866
+ const dupCheck = detectAndAutoFixDuplicates(originalLines, fileLines);
867
+ if (dupCheck.fixed !== null) {
868
+ fileLines.length = 0;
869
+ fileLines.push(...dupCheck.fixed);
956
870
  }
871
+ for (const w of dupCheck.warnings) warnings.push(w);
957
872
 
958
- // Apply append_file ops in original order. When the file ends with a
959
- // trailing newline (last split element is the empty sentinel), insert
960
- // before that sentinel so the trailing newline is preserved.
961
- appendEdits.sort((a, b) => a.idx - b.idx);
962
- for (const { edit } of appendEdits) {
963
- if (edit.lines.length === 0) continue;
964
- if (fileLines.length === 1 && fileLines[0] === "") {
965
- fileLines.splice(0, 1, ...edit.lines);
966
- trackFirstChanged(1);
967
- continue;
873
+ return {
874
+ lines: fileLines.join("\n"),
875
+ firstChangedLine,
876
+ ...(warnings.length > 0 ? { warnings } : {}),
877
+ ...(noopEdits.length > 0 && firstChangedLine === undefined ? { noopEdits } : {}),
878
+ };
879
+ }
880
+
881
+ // ═══════════════════════════════════════════════════════════════════════════
882
+ // Wire-format split: extract `---` headers from the input string.
883
+ // ═══════════════════════════════════════════════════════════════════════════
884
+
885
+ const FILE_HEADER_PREFIX = "---";
886
+ const REMOVE_FILE_OPERATION = "!rm";
887
+ const MOVE_FILE_OPERATION = "!mv";
888
+
889
+ type AtomWholeFileOperation =
890
+ | { kind: "delete"; lineNum: number }
891
+ | { kind: "move"; destination: string; lineNum: number };
892
+
893
+ interface AtomInputSection {
894
+ path: string;
895
+ diff: string;
896
+ wholeFileOperation?: AtomWholeFileOperation;
897
+ }
898
+
899
+ export interface SplitAtomOptions {
900
+ cwd?: string;
901
+ path?: string;
902
+ }
903
+
904
+ function isBlankHeaderPreamble(line: string): boolean {
905
+ return line.replace(/\r$/, "").trim().length === 0;
906
+ }
907
+
908
+ function unquoteAtomPath(pathText: string): string {
909
+ if (pathText.length < 2) return pathText;
910
+ const first = pathText[0];
911
+ const last = pathText[pathText.length - 1];
912
+ if ((first === '"' || first === "'") && first === last) {
913
+ return pathText.slice(1, -1);
914
+ }
915
+ return pathText;
916
+ }
917
+
918
+ function normalizeAtomPath(rawPath: string, cwd?: string): string {
919
+ const unquoted = unquoteAtomPath(rawPath.trim());
920
+ if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
921
+
922
+ const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
923
+ const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
924
+ return isWithinCwd ? relative || "." : unquoted;
925
+ }
926
+
927
+ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
928
+ if (!line.startsWith(FILE_HEADER_PREFIX)) return null;
929
+ let body = line.slice(FILE_HEADER_PREFIX.length);
930
+ if (body.startsWith(" ")) body = body.slice(1);
931
+ const parsedPath = normalizeAtomPath(body, cwd);
932
+ if (parsedPath.length === 0) {
933
+ throw new Error(`atom input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
934
+ }
935
+ return parsedPath;
936
+ }
937
+
938
+ function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
939
+ const trimmed = rawPath.trim();
940
+ if (trimmed.length === 0) {
941
+ throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
942
+ }
943
+
944
+ const quote = trimmed[0];
945
+ if (quote === '"' || quote === "'") {
946
+ if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
947
+ throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
968
948
  }
969
- const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
970
- const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
971
- fileLines.splice(insertIdx, 0, ...edit.lines);
972
- trackFirstChanged(insertIdx + 1);
949
+ } else if (/\s/.test(trimmed)) {
950
+ throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
973
951
  }
974
952
 
975
- // Apply sed_file ops last so they observe the post-anchor / post-prepend /
976
- // post-append state of the file. Each op runs across every content line and
977
- let warnedLiteralFallback = false;
978
- sedFileEdits.sort((a, b) => a.idx - b.idx);
979
- for (const { edit } of sedFileEdits) {
980
- const hasTrailingNewline = fileLines.length > 1 && fileLines[fileLines.length - 1] === "";
981
- const upper = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
982
- let anyMatched = false;
983
- let lastCompileError: string | undefined;
984
- for (let i = 0; i < upper; i++) {
985
- const line = fileLines[i] ?? "";
986
- const r = applySedToLine(line, edit.spec);
987
- if (r.error) lastCompileError = r.error;
988
- if (!r.matched) continue;
989
- anyMatched = true;
990
- if (r.literalFallback && !warnedLiteralFallback) {
991
- warnings.push(
992
- `sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Escape regex metacharacters in the pattern to match as a regex.`,
953
+ const destination = normalizeAtomPath(trimmed, cwd);
954
+ if (destination.length === 0) {
955
+ throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
956
+ }
957
+ return destination;
958
+ }
959
+
960
+ function parseAtomWholeFileOperationLine(
961
+ rawLine: string,
962
+ lineNum: number,
963
+ cwd?: string,
964
+ ): AtomWholeFileOperation | null {
965
+ const line = rawLine.replace(/\r$/, "").trimEnd();
966
+ if (line === REMOVE_FILE_OPERATION) {
967
+ return { kind: "delete", lineNum };
968
+ }
969
+ if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
970
+ throw new Error(`Atom line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
971
+ }
972
+
973
+ if (line === MOVE_FILE_OPERATION) {
974
+ throw new Error(`Atom line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
975
+ }
976
+ if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
977
+ const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
978
+ return {
979
+ kind: "move",
980
+ destination: parseSingleAtomPathArgument(rawDestination, MOVE_FILE_OPERATION, lineNum, cwd),
981
+ lineNum,
982
+ };
983
+ }
984
+
985
+ return null;
986
+ }
987
+
988
+ function getAtomWholeFileOperation(
989
+ sectionPath: string,
990
+ lines: string[],
991
+ cwd?: string,
992
+ ): AtomWholeFileOperation | undefined {
993
+ let operation: AtomWholeFileOperation | undefined;
994
+ let operationToken = "";
995
+ let hasLineEdit = false;
996
+
997
+ for (let i = 0; i < lines.length; i++) {
998
+ const lineNum = i + 1;
999
+ const line = lines[i].replace(/\r$/, "");
1000
+ if (line.trim().length === 0) continue;
1001
+
1002
+ const parsed = parseAtomWholeFileOperationLine(line, lineNum, cwd);
1003
+ if (parsed) {
1004
+ if (operation) {
1005
+ throw new Error(
1006
+ `Atom section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
993
1007
  );
994
- warnedLiteralFallback = true;
995
- }
996
- if (r.result !== line) {
997
- fileLines[i] = r.result;
998
- trackFirstChanged(i + 1);
999
- }
1000
- }
1001
- if (!anyMatched) {
1002
- if (lastCompileError !== undefined) {
1003
- throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${lastCompileError}`);
1004
1008
  }
1005
- throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
1009
+ operation = parsed;
1010
+ operationToken = parsed.kind === "delete" ? REMOVE_FILE_OPERATION : MOVE_FILE_OPERATION;
1011
+ continue;
1006
1012
  }
1013
+
1014
+ hasLineEdit = true;
1007
1015
  }
1008
1016
 
1009
- return {
1010
- lines: fileLines.join("\n"),
1011
- firstChangedLine,
1012
- ...(warnings.length > 0 ? { warnings } : {}),
1013
- ...(noopEdits.length > 0 ? { noopEdits } : {}),
1017
+ if (operation && hasLineEdit) {
1018
+ throw new Error(
1019
+ `Atom section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
1020
+ );
1021
+ }
1022
+
1023
+ return operation;
1024
+ }
1025
+
1026
+ function hasAtomHeaderLine(input: string): boolean {
1027
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1028
+ return stripped.split("\n").some(rawLine => rawLine.replace(/\r$/, "").startsWith(FILE_HEADER_PREFIX));
1029
+ }
1030
+
1031
+ function containsRecognizableAtomOperations(input: string): boolean {
1032
+ for (const rawLine of input.split("\n")) {
1033
+ const line = rawLine.replace(/\r$/, "");
1034
+ if (line.length === 0) continue;
1035
+ if (line[0] === "+") return true;
1036
+ if (line === "$" || line === "^") return true;
1037
+ if (/^- ?[1-9]\d*[a-z]{2}(?: .*)?$/.test(line)) return true;
1038
+ if (/^@?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?$/.test(line)) return true;
1039
+ if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
1040
+ }
1041
+ return false;
1042
+ }
1043
+
1044
+ function stripLeadingBlankLines(input: string): string {
1045
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1046
+ const lines = stripped.split("\n");
1047
+ while (lines.length > 0 && isBlankHeaderPreamble(lines[0] ?? "")) {
1048
+ lines.shift();
1049
+ }
1050
+ return lines.join("\n");
1051
+ }
1052
+
1053
+ function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
1054
+ if (hasAtomHeaderLine(input) || !options.path || !containsRecognizableAtomOperations(input)) {
1055
+ return input;
1056
+ }
1057
+ const fallbackPath = normalizeAtomPath(options.path, options.cwd);
1058
+ if (fallbackPath.length === 0) return input;
1059
+ return `${FILE_HEADER_PREFIX}${fallbackPath}\n${input}`;
1060
+ }
1061
+
1062
+ function getTextContent(result: AgentToolResult<EditToolDetails>): string {
1063
+ return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
1064
+ }
1065
+
1066
+ function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
1067
+ if (result.details === undefined) {
1068
+ return { diff: "" };
1069
+ }
1070
+ return result.details;
1071
+ }
1072
+
1073
+ /**
1074
+ * Split the wire-format `input` string into `{ path, diff }`. The first
1075
+ * non-empty line MUST be `---<path>` or `--- <path>`. Tolerates a leading BOM.
1076
+ */
1077
+ export function splitAtomInput(input: string, options: SplitAtomOptions = {}): { path: string; diff: string } {
1078
+ const [section] = splitAtomInputs(input, options);
1079
+ return section;
1080
+ }
1081
+
1082
+ export function splitAtomInputs(input: string, options: SplitAtomOptions = {}): AtomInputSection[] {
1083
+ const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
1084
+ const lines = stripped.split("\n");
1085
+ const firstLine = (lines[0] ?? "").replace(/\r$/, "");
1086
+ if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
1087
+ throw new Error(
1088
+ `atom input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${JSON.stringify(
1089
+ firstLine.slice(0, 120),
1090
+ )}`,
1091
+ );
1092
+ }
1093
+
1094
+ const sections: AtomInputSection[] = [];
1095
+ let currentPath = "";
1096
+ let currentLines: string[] = [];
1097
+ const flush = () => {
1098
+ if (currentPath.length === 0) return;
1099
+ const wholeFileOperation = getAtomWholeFileOperation(currentPath, currentLines, options.cwd);
1100
+ sections.push({
1101
+ path: currentPath,
1102
+ diff: currentLines.join("\n"),
1103
+ ...(wholeFileOperation ? { wholeFileOperation } : {}),
1104
+ });
1105
+ currentLines = [];
1014
1106
  };
1107
+
1108
+ for (const rawLine of lines) {
1109
+ const line = rawLine.replace(/\r$/, "");
1110
+ const headerPath = parseAtomHeaderLine(line, options.cwd);
1111
+ if (headerPath !== null) {
1112
+ flush();
1113
+ currentPath = headerPath;
1114
+ continue;
1115
+ }
1116
+ currentLines.push(rawLine);
1117
+ }
1118
+ flush();
1119
+ return sections;
1015
1120
  }
1016
1121
 
1017
- // ═══════════════════════════════════════════════════════════════════════════
1122
+ // ═════════════════════════════════════════════════════════════════════════════
1018
1123
  // Executor
1019
1124
  // ═══════════════════════════════════════════════════════════════════════════
1020
1125
 
1021
1126
  export interface ExecuteAtomSingleOptions {
1022
1127
  session: ToolSession;
1023
- path: string;
1024
- edits: AtomToolEdit[];
1128
+ input: string;
1129
+ path?: string;
1025
1130
  signal?: AbortSignal;
1026
1131
  batchRequest?: LspBatchRequest;
1027
1132
  writethrough: WritethroughCallback;
1028
1133
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1029
1134
  }
1030
1135
 
1031
- export async function executeAtomSingle(
1032
- options: ExecuteAtomSingleOptions,
1033
- ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
1034
- const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1136
+ interface ReadAtomFileResult {
1137
+ exists: boolean;
1138
+ rawContent: string;
1139
+ }
1035
1140
 
1036
- const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i, path));
1141
+ async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
1142
+ try {
1143
+ return { exists: true, rawContent: await Bun.file(absolutePath).text() };
1144
+ } catch (error) {
1145
+ if (isEnoent(error)) return { exists: false, rawContent: "" };
1146
+ throw error;
1147
+ }
1148
+ }
1037
1149
 
1038
- enforcePlanModeWrite(session, path, { op: "update" });
1150
+ function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
1151
+ return edits.some(edit => edit.kind === "set" || edit.kind === "delete" || edit.cursor.kind === "anchor");
1152
+ }
1039
1153
 
1040
- if (path.endsWith(".ipynb") && contentEdits.length > 0) {
1041
- throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1154
+ function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
1155
+ let diagnostic = `Edits to ${path} resulted in no changes being made.`;
1156
+ if (result.noopEdits && result.noopEdits.length > 0) {
1157
+ const details = result.noopEdits
1158
+ .map(e => {
1159
+ const preview =
1160
+ e.current.length > 0
1161
+ ? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
1162
+ : "";
1163
+ return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
1164
+ })
1165
+ .join("\n");
1166
+ diagnostic += `\n${details}`;
1042
1167
  }
1168
+ return diagnostic;
1169
+ }
1043
1170
 
1044
- const absolutePath = resolvePlanPath(session, path);
1171
+ async function executeAtomWholeFileOperation(
1172
+ options: ExecuteAtomSingleOptions & AtomInputSection & { wholeFileOperation: AtomWholeFileOperation },
1173
+ ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
1174
+ const { session, path: sectionPath, wholeFileOperation } = options;
1175
+ const absolutePath = resolvePlanPath(session, sectionPath);
1045
1176
 
1046
- const sourceFile = Bun.file(absolutePath);
1047
- const sourceExists = await sourceFile.exists();
1048
-
1049
- if (!sourceExists) {
1050
- const lines: string[] = [];
1051
- for (const edit of contentEdits) {
1052
- if (edit.op === "append_file") {
1053
- lines.push(...edit.lines);
1054
- } else if (edit.op === "prepend_file") {
1055
- lines.unshift(...edit.lines);
1056
- } else {
1057
- throw new Error(`File not found: ${path}`);
1058
- }
1059
- }
1177
+ if (sectionPath.endsWith(".ipynb")) {
1178
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1179
+ }
1060
1180
 
1061
- await Bun.write(absolutePath, lines.join("\n"));
1062
- invalidateFsScanAfterWrite(absolutePath);
1181
+ if (wholeFileOperation.kind === "delete") {
1182
+ enforcePlanModeWrite(session, sectionPath, { op: "delete" });
1183
+ await assertEditableFile(absolutePath, sectionPath);
1184
+ try {
1185
+ await fs.unlink(absolutePath);
1186
+ } catch (error) {
1187
+ if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
1188
+ throw error;
1189
+ }
1190
+ invalidateFsScanAfterDelete(absolutePath);
1063
1191
  return {
1064
- content: [{ type: "text", text: `Created ${path}` }],
1065
- details: {
1066
- diff: "",
1067
- op: "create",
1068
- meta: outputMeta().get(),
1069
- },
1192
+ content: [{ type: "text", text: `Deleted ${sectionPath}` }],
1193
+ details: { diff: "", op: "delete", meta: outputMeta().get() },
1070
1194
  };
1071
1195
  }
1072
1196
 
1073
- const rawContent = await sourceFile.text();
1074
- assertEditableFileContent(rawContent, path);
1197
+ const destinationPath = wholeFileOperation.destination;
1198
+ if (destinationPath.endsWith(".ipynb")) {
1199
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1200
+ }
1201
+
1202
+ enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
1203
+ const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
1204
+ if (absoluteDestinationPath === absolutePath) {
1205
+ throw new Error("rename path is the same as source path");
1206
+ }
1207
+
1208
+ await assertEditableFile(absolutePath, sectionPath);
1209
+ try {
1210
+ await fs.mkdir(path.dirname(absoluteDestinationPath), { recursive: true });
1211
+ await fs.rename(absolutePath, absoluteDestinationPath);
1212
+ } catch (error) {
1213
+ if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
1214
+ throw error;
1215
+ }
1216
+ invalidateFsScanAfterRename(absolutePath, absoluteDestinationPath);
1217
+
1218
+ return {
1219
+ content: [{ type: "text", text: `Moved ${sectionPath} to ${destinationPath}` }],
1220
+ details: { diff: "", op: "update", move: destinationPath, meta: outputMeta().get() },
1221
+ };
1222
+ }
1223
+
1224
+ async function executeAtomSection(
1225
+ options: ExecuteAtomSingleOptions & AtomInputSection,
1226
+ ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
1227
+ const { session, path, diff, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1228
+ if (options.wholeFileOperation) {
1229
+ return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
1230
+ }
1231
+
1232
+ const edits = parseAtom(diff);
1233
+ if (edits.length === 0 && diff.trim().length > 0) {
1234
+ throw new Error(formatNoAtomEditDiagnostic(path, diff));
1235
+ }
1236
+
1237
+ enforcePlanModeWrite(session, path, { op: "update" });
1238
+
1239
+ if (path.endsWith(".ipynb") && edits.length > 0) {
1240
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1241
+ }
1242
+
1243
+ const absolutePath = resolvePlanPath(session, path);
1244
+ const source = await readAtomFile(absolutePath);
1245
+ if (!source.exists && hasAnchorScopedEdit(edits)) {
1246
+ throw new Error(`File not found: ${path}`);
1247
+ }
1248
+
1249
+ if (source.exists) {
1250
+ assertEditableFileContent(source.rawContent, path);
1251
+ }
1075
1252
 
1076
- const { bom, text } = stripBom(rawContent);
1253
+ const { bom, text } = stripBom(source.rawContent);
1077
1254
  const originalEnding = detectLineEnding(text);
1078
1255
  const originalNormalized = normalizeToLF(text);
1079
-
1080
- const result = applyAtomEdits(originalNormalized, contentEdits);
1256
+ const result = applyAtomEdits(originalNormalized, edits);
1081
1257
  if (originalNormalized === result.lines) {
1082
- let diagnostic = `Edits to ${path} resulted in no changes being made.`;
1083
- if (result.noopEdits && result.noopEdits.length > 0) {
1084
- const details = result.noopEdits
1085
- .map(e => {
1086
- const preview =
1087
- e.current.length > 0
1088
- ? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
1089
- : "";
1090
- return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
1091
- })
1092
- .join("\n");
1093
- diagnostic += `\n${details}`;
1094
- }
1095
- throw new Error(diagnostic);
1258
+ throw new Error(formatNoChangeDiagnostic(path, result));
1096
1259
  }
1097
1260
 
1098
1261
  const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
@@ -1110,31 +1273,66 @@ export async function executeAtomSingle(
1110
1273
  const meta = outputMeta()
1111
1274
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1112
1275
  .get();
1113
-
1114
- const resultText = `Updated ${path}`;
1115
1276
  const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1116
- const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
1117
- preview.preview ? "" : " (no textual diff preview)"
1118
- }`;
1119
1277
  const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
1120
- const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
1278
+ const previewBlock = preview.preview ? `\n${preview.preview}` : "";
1279
+ const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
1121
1280
 
1122
1281
  return {
1123
1282
  content: [
1124
1283
  {
1125
1284
  type: "text",
1126
- text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
1285
+ text: `${resultText}${previewBlock}${warningsBlock}`,
1127
1286
  },
1128
1287
  ],
1129
1288
  details: {
1130
1289
  diff: diffResult.diff,
1131
1290
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1132
1291
  diagnostics,
1133
- op: "update",
1292
+ op: source.exists ? "update" : "create",
1134
1293
  meta,
1135
1294
  },
1136
1295
  };
1137
1296
  }
1138
1297
 
1139
- // Helpers exposed for tests / external dispatch.
1140
- export { classifyAtomEdit, parseAnchor, resolveAtomToolEdit };
1298
+ export async function executeAtomSingle(
1299
+ options: ExecuteAtomSingleOptions,
1300
+ ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
1301
+ const sections = splitAtomInputs(options.input, { cwd: options.session.cwd, path: options.path });
1302
+ if (sections.length === 1) {
1303
+ const [section] = sections;
1304
+ return executeAtomSection({ ...options, ...section });
1305
+ }
1306
+
1307
+ const results = [];
1308
+ for (const section of sections) {
1309
+ results.push({
1310
+ path: section.path,
1311
+ result: await executeAtomSection({ ...options, ...section }),
1312
+ });
1313
+ }
1314
+
1315
+ return {
1316
+ content: [
1317
+ {
1318
+ type: "text",
1319
+ text: results.map(({ result }) => getTextContent(result)).join("\n\n"),
1320
+ },
1321
+ ],
1322
+ details: {
1323
+ diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
1324
+ perFileResults: results.map(({ path, result }) => {
1325
+ const details = getEditDetails(result);
1326
+ return {
1327
+ path,
1328
+ diff: details.diff,
1329
+ firstChangedLine: details.firstChangedLine,
1330
+ diagnostics: details.diagnostics,
1331
+ op: details.op,
1332
+ move: details.move,
1333
+ meta: details.meta,
1334
+ };
1335
+ }),
1336
+ },
1337
+ };
1338
+ }