@oh-my-pi/pi-coding-agent 14.2.1 → 14.4.0

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 (137) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/package.json +19 -19
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/cli/args.ts +10 -1
  5. package/src/cli/shell-cli.ts +15 -3
  6. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  7. package/src/config/model-registry.ts +67 -15
  8. package/src/config/prompt-templates.ts +5 -5
  9. package/src/config/settings-schema.ts +63 -4
  10. package/src/cursor.ts +3 -8
  11. package/src/debug/system-info.ts +6 -2
  12. package/src/discovery/claude.ts +58 -36
  13. package/src/discovery/helpers.ts +3 -3
  14. package/src/discovery/opencode.ts +20 -2
  15. package/src/edit/diff.ts +50 -47
  16. package/src/edit/index.ts +87 -57
  17. package/src/edit/line-hash.ts +735 -19
  18. package/src/edit/modes/apply-patch.ts +0 -9
  19. package/src/edit/modes/atom.ts +658 -0
  20. package/src/edit/modes/chunk.ts +144 -78
  21. package/src/edit/modes/hashline.ts +223 -146
  22. package/src/edit/modes/patch.ts +5 -9
  23. package/src/edit/modes/replace.ts +6 -11
  24. package/src/edit/renderer.ts +112 -143
  25. package/src/edit/streaming.ts +385 -0
  26. package/src/exec/bash-executor.ts +58 -5
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +4 -12
  29. package/src/extensibility/custom-tools/types.ts +2 -0
  30. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  31. package/src/internal-urls/docs-index.generated.ts +7 -7
  32. package/src/internal-urls/pi-protocol.ts +0 -2
  33. package/src/lsp/client.ts +8 -1
  34. package/src/lsp/defaults.json +2 -1
  35. package/src/lsp/index.ts +1 -1
  36. package/src/mcp/render.ts +1 -8
  37. package/src/modes/acp/acp-agent.ts +76 -2
  38. package/src/modes/components/assistant-message.ts +5 -34
  39. package/src/modes/components/diff.ts +23 -14
  40. package/src/modes/components/footer.ts +21 -16
  41. package/src/modes/components/hook-editor.ts +1 -1
  42. package/src/modes/components/settings-defs.ts +6 -1
  43. package/src/modes/components/todo-reminder.ts +1 -8
  44. package/src/modes/components/tool-execution.ts +112 -105
  45. package/src/modes/controllers/input-controller.ts +1 -1
  46. package/src/modes/controllers/selector-controller.ts +1 -1
  47. package/src/modes/interactive-mode.ts +0 -2
  48. package/src/modes/print-mode.ts +8 -0
  49. package/src/modes/theme/mermaid-cache.ts +13 -52
  50. package/src/modes/theme/theme.ts +2 -2
  51. package/src/prompts/agents/librarian.md +1 -1
  52. package/src/prompts/agents/reviewer.md +4 -4
  53. package/src/prompts/ci-green-request.md +1 -1
  54. package/src/prompts/review-request.md +1 -1
  55. package/src/prompts/system/subagent-system-prompt.md +3 -3
  56. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  57. package/src/prompts/system/system-prompt.md +4 -1
  58. package/src/prompts/tools/ask.md +3 -2
  59. package/src/prompts/tools/ast-edit.md +15 -19
  60. package/src/prompts/tools/ast-grep.md +18 -24
  61. package/src/prompts/tools/atom.md +96 -0
  62. package/src/prompts/tools/browser.md +1 -0
  63. package/src/prompts/tools/chunk-edit.md +58 -179
  64. package/src/prompts/tools/debug.md +4 -5
  65. package/src/prompts/tools/exit-plan-mode.md +4 -5
  66. package/src/prompts/tools/find.md +4 -8
  67. package/src/prompts/tools/github.md +18 -0
  68. package/src/prompts/tools/grep.md +8 -8
  69. package/src/prompts/tools/hashline.md +22 -89
  70. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  71. package/src/prompts/tools/inspect-image.md +6 -6
  72. package/src/prompts/tools/lsp.md +6 -0
  73. package/src/prompts/tools/patch.md +12 -19
  74. package/src/prompts/tools/python.md +3 -2
  75. package/src/prompts/tools/read-chunk.md +46 -8
  76. package/src/prompts/tools/read.md +9 -6
  77. package/src/prompts/tools/ssh.md +8 -17
  78. package/src/prompts/tools/todo-write.md +54 -41
  79. package/src/sdk.ts +22 -14
  80. package/src/session/agent-session.ts +61 -22
  81. package/src/session/session-manager.ts +228 -57
  82. package/src/session/streaming-output.ts +11 -0
  83. package/src/system-prompt.ts +7 -2
  84. package/src/task/executor.ts +44 -48
  85. package/src/task/render.ts +11 -13
  86. package/src/tools/ask.ts +7 -7
  87. package/src/tools/ast-edit.ts +45 -41
  88. package/src/tools/ast-grep.ts +77 -85
  89. package/src/tools/bash.ts +21 -9
  90. package/src/tools/browser.ts +32 -30
  91. package/src/tools/calculator.ts +4 -4
  92. package/src/tools/cancel-job.ts +1 -1
  93. package/src/tools/checkpoint.ts +2 -2
  94. package/src/tools/debug.ts +41 -37
  95. package/src/tools/exit-plan-mode.ts +1 -1
  96. package/src/tools/find.ts +4 -4
  97. package/src/tools/gh-renderer.ts +12 -4
  98. package/src/tools/gh.ts +514 -712
  99. package/src/tools/grep.ts +115 -130
  100. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  101. package/src/tools/index.ts +14 -32
  102. package/src/tools/inspect-image.ts +3 -3
  103. package/src/tools/json-tree.ts +114 -114
  104. package/src/tools/match-line-format.ts +9 -8
  105. package/src/tools/notebook.ts +8 -7
  106. package/src/tools/poll-tool.ts +2 -1
  107. package/src/tools/python.ts +9 -23
  108. package/src/tools/read.ts +32 -21
  109. package/src/tools/render-mermaid.ts +1 -1
  110. package/src/tools/render-utils.ts +18 -0
  111. package/src/tools/renderers.ts +2 -2
  112. package/src/tools/report-tool-issue.ts +3 -2
  113. package/src/tools/resolve.ts +1 -1
  114. package/src/tools/review.ts +12 -10
  115. package/src/tools/search-tool-bm25.ts +2 -4
  116. package/src/tools/sqlite-reader.ts +116 -3
  117. package/src/tools/ssh.ts +4 -4
  118. package/src/tools/todo-write.ts +172 -147
  119. package/src/tools/vim.ts +14 -15
  120. package/src/tools/write.ts +4 -4
  121. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  122. package/src/utils/edit-mode.ts +2 -1
  123. package/src/utils/file-display-mode.ts +10 -5
  124. package/src/utils/git.ts +9 -5
  125. package/src/utils/shell-snapshot.ts +2 -3
  126. package/src/vim/render.ts +4 -4
  127. package/src/web/search/providers/codex.ts +129 -6
  128. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  129. package/src/prompts/tools/gh-issue-view.md +0 -11
  130. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  131. package/src/prompts/tools/gh-pr-diff.md +0 -12
  132. package/src/prompts/tools/gh-pr-push.md +0 -11
  133. package/src/prompts/tools/gh-pr-view.md +0 -11
  134. package/src/prompts/tools/gh-repo-view.md +0 -11
  135. package/src/prompts/tools/gh-run-watch.md +0 -12
  136. package/src/prompts/tools/gh-search-issues.md +0 -11
  137. package/src/prompts/tools/gh-search-prs.md +0 -11
@@ -22,15 +22,6 @@ export const applyPatchSchema = Type.Object({
22
22
 
23
23
  export type ApplyPatchParams = Static<typeof applyPatchSchema>;
24
24
 
25
- export function isApplyPatchParams(params: unknown): params is ApplyPatchParams {
26
- return (
27
- typeof params === "object" &&
28
- params !== null &&
29
- "input" in params &&
30
- typeof (params as { input: unknown }).input === "string"
31
- );
32
- }
33
-
34
25
  /**
35
26
  * Parse the envelope and lower each hunk to a `PatchEditEntry` so it can
36
27
  * be routed through `executePatchSingle`.
@@ -0,0 +1,658 @@
1
+ /**
2
+ *
3
+ * Flat locator + verb edit mode backed by hashline anchors. Each entry carries
4
+ * one shared `loc` selector plus one or more verbs (`pre`, `set`, `post`).
5
+ * The runtime resolves those verbs into internal anchor-scoped edits and still
6
+ * reuses hashline's staleness scheme (`computeLineHash`) verbatim.
7
+ *
8
+ * External shapes (one entry):
9
+ * { path, loc: "5th", set: ["..."] }
10
+ * { path, loc: "5th", pre: ["..."] }
11
+ * { path, loc: "5th", post: ["..."] }
12
+ * { path, loc: "5th", pre: [...], set: [...], post: [...] }
13
+ * { path, loc: "^", pre: [...] } // prepend to BOF
14
+ * { path, loc: "$", post: [...] } // append to EOF
15
+ *
16
+ * `set: []` on a single-anchor locator deletes that line. `set:[""]` preserves
17
+ * a blank line. Line ranges are not supported.
18
+ * in the same entry.
19
+ *
20
+ * For deleting or moving files, the agent should use bash.
21
+ */
22
+
23
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
24
+ import { type Static, Type } from "@sinclair/typebox";
25
+ import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
26
+ import type { ToolSession } from "../../tools";
27
+ import { assertEditableFileContent } from "../../tools/auto-generated-guard";
28
+ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
29
+ import { outputMeta } from "../../tools/output-meta";
30
+ import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
31
+ import { generateDiffString } from "../diff";
32
+ import { computeLineHash } from "../line-hash";
33
+ import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
34
+ import type { EditToolDetails, LspBatchRequest } from "../renderer";
35
+ import {
36
+ ANCHOR_REBASE_WINDOW,
37
+ type Anchor,
38
+ buildCompactHashlineDiffPreview,
39
+ formatFullAnchorRequirement,
40
+ HashlineMismatchError,
41
+ type HashMismatch,
42
+ hashlineParseText,
43
+ parseTag,
44
+ tryRebaseAnchor,
45
+ } from "./hashline";
46
+
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+ // Schema
49
+ // ═══════════════════════════════════════════════════════════════════════════
50
+ const textSchema = Type.Array(Type.String());
51
+
52
+ /**
53
+ * Flat entry shape with shared locator fields and verb-specific payloads.
54
+ * The runtime validator (`resolveAtomToolEdit`) enforces legal locator/verb
55
+ * combinations. Keeping the schema flat reduces tool-definition size and gives
56
+ * weaker models fewer branching shapes to sample from.
57
+ */
58
+ export const atomEditSchema = Type.Object(
59
+ {
60
+ path: Type.Optional(Type.String({ description: "file path override", examples: ["src/foo.ts"] })),
61
+ loc: Type.String({
62
+ description: 'edit location: "1ab", "^", "$", or path override like "a.ts:1ab"',
63
+ examples: ["1ab", "^", "$", "src/foo.ts:1ab"],
64
+ }),
65
+ set: Type.Optional(textSchema),
66
+ pre: Type.Optional(textSchema),
67
+ post: Type.Optional(textSchema),
68
+ },
69
+ { additionalProperties: false },
70
+ );
71
+
72
+ export const atomEditParamsSchema = Type.Object(
73
+ {
74
+ path: Type.Optional(Type.String({ description: "default file path for edits" })),
75
+ edits: Type.Array(atomEditSchema, { description: "edit ops" }),
76
+ },
77
+ { additionalProperties: false },
78
+ );
79
+
80
+ export type AtomToolEdit = Static<typeof atomEditSchema>;
81
+ export type AtomParams = Static<typeof atomEditParamsSchema>;
82
+
83
+ // ═══════════════════════════════════════════════════════════════════════════
84
+ // Internal resolved op shapes
85
+ // ═══════════════════════════════════════════════════════════════════════════
86
+
87
+ export type AtomEdit =
88
+ | { op: "set"; pos: Anchor; lines: string[] }
89
+ | { op: "pre"; pos: Anchor; lines: string[] }
90
+ | { op: "post"; pos: Anchor; lines: string[] }
91
+ | { op: "del"; pos: Anchor }
92
+ | { op: "append_file"; lines: string[] }
93
+ | { op: "prepend_file"; lines: string[] };
94
+
95
+ // ═══════════════════════════════════════════════════════════════════════════
96
+ // Param guards
97
+ // ═══════════════════════════════════════════════════════════════════════════
98
+
99
+ const ATOM_VERB_KEYS = ["set", "pre", "post"] as const;
100
+ type AtomOptionalKey = "path" | "loc" | (typeof ATOM_VERB_KEYS)[number];
101
+ const ATOM_OPTIONAL_KEYS = ["path", "loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
102
+
103
+ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
104
+ let next: Record<string, unknown> | undefined;
105
+ const fields = edit as Record<string, unknown>;
106
+ for (const key of ATOM_OPTIONAL_KEYS) {
107
+ if (fields[key] !== null) continue;
108
+ next ??= { ...fields };
109
+ delete next[key];
110
+ }
111
+ return (next ?? fields) as AtomToolEdit;
112
+ }
113
+
114
+ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind: "eof" };
115
+
116
+ // ═══════════════════════════════════════════════════════════════════════════
117
+ // Resolution
118
+ // ═══════════════════════════════════════════════════════════════════════════
119
+
120
+ /**
121
+ * Parse an anchor reference like `"5th"`.
122
+ *
123
+ * Tolerant: on a malformed reference we still try to extract a 1-indexed line
124
+ * number from the leading digits so the validator can surface the *correct*
125
+ * `LINEHASH:content` for the user. The bogus hash is preserved in the returned
126
+ * anchor so the validator emits a content-rich mismatch error.
127
+ *
128
+ * If we cannot recover even a line number, throw a usage-style error with the
129
+ * raw reference quoted.
130
+ */
131
+ function parseAnchor(raw: string, opName: string): Anchor {
132
+ if (typeof raw !== "string" || raw.length === 0) {
133
+ throw new Error(`${opName} requires ${formatFullAnchorRequirement()}.`);
134
+ }
135
+ try {
136
+ return parseTag(raw);
137
+ } catch {
138
+ const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
139
+ if (lineMatch) {
140
+ const line = Number.parseInt(lineMatch[1], 10);
141
+ if (line >= 1) {
142
+ // Sentinel hash that will never match a real line, forcing the validator
143
+ // to report a mismatch with the actual hash + line content.
144
+ return { line, hash: "??" };
145
+ }
146
+ }
147
+ throw new Error(
148
+ `${opName} requires ${formatFullAnchorRequirement(raw)} Could not find a line number in the anchor.`,
149
+ );
150
+ }
151
+ }
152
+
153
+ function tryParseAtomTag(raw: string): Anchor | undefined {
154
+ try {
155
+ return parseTag(raw);
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ function isLocSelector(raw: string): boolean {
162
+ if (raw === "^" || raw === "$") return true;
163
+ const dash = raw.indexOf("-");
164
+ if (dash === -1) return tryParseAtomTag(raw) !== undefined;
165
+ const left = raw.slice(0, dash);
166
+ const right = raw.slice(dash + 1);
167
+ if (left.length === 0 || right.length === 0) return false;
168
+ return tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined;
169
+ }
170
+
171
+ function resolveAtomEntryPath(
172
+ edit: AtomToolEdit,
173
+ topLevelPath: string | undefined,
174
+ editIndex: number,
175
+ ): AtomToolEdit & { path: string } {
176
+ const entry = stripNullAtomFields(edit);
177
+ let loc = entry.loc;
178
+ let pathOverride: string | undefined;
179
+ if (typeof loc === "string") {
180
+ const colon = loc.lastIndexOf(":");
181
+ if (colon > 0) {
182
+ const maybeSelector = loc.slice(colon + 1);
183
+ if (isLocSelector(maybeSelector)) {
184
+ pathOverride = loc.slice(0, colon);
185
+ loc = maybeSelector;
186
+ }
187
+ }
188
+ }
189
+ const path = pathOverride || entry.path || topLevelPath;
190
+ if (!path) {
191
+ throw new Error(
192
+ `Edit ${editIndex}: missing path. Provide a top-level path, per-entry path, or prefix loc with a file path (for example "a.ts:160sr").`,
193
+ );
194
+ }
195
+ return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
196
+ }
197
+
198
+ export function resolveAtomEntryPaths(
199
+ edits: readonly AtomToolEdit[],
200
+ topLevelPath: string | undefined,
201
+ ): (AtomToolEdit & { path: string })[] {
202
+ return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i));
203
+ }
204
+
205
+ function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
206
+ if (raw === "^") return { kind: "bof" };
207
+ if (raw === "$") return { kind: "eof" };
208
+ if (raw.includes("-")) {
209
+ throw new Error(
210
+ `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr", "^", or "$".`,
211
+ );
212
+ }
213
+ return { kind: "anchor", pos: parseAnchor(raw, "loc") };
214
+ }
215
+
216
+ function classifyAtomEdit(edit: AtomToolEdit): string {
217
+ const entry = stripNullAtomFields(edit);
218
+ const verbs = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
219
+ return verbs.length > 0 ? verbs.join("+") : "unknown";
220
+ }
221
+
222
+ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
223
+ const entry = stripNullAtomFields(edit);
224
+ const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
225
+ if (verbKeysPresent.length === 0) {
226
+ throw new Error(
227
+ `Edit ${editIndex}: missing verb. Each entry must include at least one of: ${ATOM_VERB_KEYS.join(", ")}.`,
228
+ );
229
+ }
230
+ if (typeof entry.loc !== "string") {
231
+ throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr", "^", or "$".`);
232
+ }
233
+
234
+ const loc = parseLoc(entry.loc, editIndex);
235
+ const resolved: AtomEdit[] = [];
236
+
237
+ if (loc.kind === "bof") {
238
+ if (entry.set !== undefined || entry.post !== undefined) {
239
+ throw new Error(`Edit ${editIndex}: loc "^" only supports pre.`);
240
+ }
241
+ if (entry.pre !== undefined) {
242
+ resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
243
+ }
244
+ return resolved;
245
+ }
246
+
247
+ if (loc.kind === "eof") {
248
+ if (entry.set !== undefined || entry.pre !== undefined) {
249
+ throw new Error(`Edit ${editIndex}: loc "$" only supports post.`);
250
+ }
251
+ if (entry.post !== undefined) {
252
+ resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
253
+ }
254
+ return resolved;
255
+ }
256
+
257
+ if (entry.pre !== undefined) {
258
+ resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
259
+ }
260
+ if (entry.set !== undefined) {
261
+ if (Array.isArray(entry.set) && entry.set.length === 0) {
262
+ resolved.push({ op: "del", pos: loc.pos });
263
+ } else {
264
+ resolved.push({ op: "set", pos: loc.pos, lines: hashlineParseText(entry.set) });
265
+ }
266
+ }
267
+ if (entry.post !== undefined) {
268
+ resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
269
+ }
270
+ return resolved;
271
+ }
272
+
273
+ // ═══════════════════════════════════════════════════════════════════════════
274
+ // Validation
275
+ // ═══════════════════════════════════════════════════════════════════════════
276
+
277
+ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
278
+ switch (edit.op) {
279
+ case "set":
280
+ case "pre":
281
+ case "post":
282
+ case "del":
283
+ yield edit.pos;
284
+ return;
285
+ default:
286
+ return;
287
+ }
288
+ }
289
+
290
+ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
291
+ const mismatches: HashMismatch[] = [];
292
+ for (const edit of edits) {
293
+ for (const anchor of getAtomAnchors(edit)) {
294
+ if (anchor.line < 1 || anchor.line > fileLines.length) {
295
+ throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
296
+ }
297
+ const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
298
+ if (actualHash === anchor.hash) continue;
299
+ const rebased = tryRebaseAnchor(anchor, fileLines);
300
+ if (rebased !== null) {
301
+ const original = `${anchor.line}${anchor.hash}`;
302
+ anchor.line = rebased;
303
+ warnings.push(
304
+ `Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
305
+ );
306
+ continue;
307
+ }
308
+ mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
309
+ }
310
+ }
311
+ return mismatches;
312
+ }
313
+
314
+ function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
315
+ // For each anchor line, at most one mutating op (set/del).
316
+ // `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
317
+ const mutatingPerLine = new Map<number, string>();
318
+ for (const edit of edits) {
319
+ if (edit.op !== "set" && edit.op !== "del") continue;
320
+ const existing = mutatingPerLine.get(edit.pos.line);
321
+ if (existing) {
322
+ throw new Error(
323
+ `Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
324
+ `At most one of set/del is allowed per anchor.`,
325
+ );
326
+ }
327
+ mutatingPerLine.set(edit.pos.line, edit.op);
328
+ }
329
+ }
330
+
331
+ // ═══════════════════════════════════════════════════════════════════════════
332
+ // Apply
333
+ // ═══════════════════════════════════════════════════════════════════════════
334
+
335
+ function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
336
+ const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
337
+ if (!enabled) return;
338
+ for (const edit of edits) {
339
+ if (edit.op !== "set" && edit.op !== "pre" && edit.op !== "post") continue;
340
+ if (edit.lines.length === 0) continue;
341
+ const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
342
+ if (!hasEscapedTabs) continue;
343
+ const hasRealTabs = edit.lines.some(line => line.includes("\t"));
344
+ if (hasRealTabs) continue;
345
+ let correctedCount = 0;
346
+ const corrected = edit.lines.map(line =>
347
+ line.replace(/^((?:\\t)+)/, escaped => {
348
+ correctedCount += escaped.length / 2;
349
+ return "\t".repeat(escaped.length / 2);
350
+ }),
351
+ );
352
+ if (correctedCount === 0) continue;
353
+ edit.lines = corrected;
354
+ warnings.push(
355
+ `Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
356
+ );
357
+ }
358
+ }
359
+
360
+ export interface AtomNoopEdit {
361
+ editIndex: number;
362
+ loc: string;
363
+ reason: string;
364
+ current: string;
365
+ }
366
+
367
+ export function applyAtomEdits(
368
+ text: string,
369
+ edits: AtomEdit[],
370
+ ): {
371
+ lines: string;
372
+ firstChangedLine: number | undefined;
373
+ warnings?: string[];
374
+ noopEdits?: AtomNoopEdit[];
375
+ } {
376
+ if (edits.length === 0) {
377
+ return { lines: text, firstChangedLine: undefined };
378
+ }
379
+
380
+ const fileLines = text.split("\n");
381
+ const warnings: string[] = [];
382
+ let firstChangedLine: number | undefined;
383
+ const noopEdits: AtomNoopEdit[] = [];
384
+
385
+ const mismatches = validateAtomAnchors(edits, fileLines, warnings);
386
+ if (mismatches.length > 0) {
387
+ throw new HashlineMismatchError(mismatches, fileLines);
388
+ }
389
+ validateNoConflictingAnchorOps(edits);
390
+ maybeAutocorrectEscapedTabIndentation(edits, warnings);
391
+
392
+ const trackFirstChanged = (line: number) => {
393
+ if (firstChangedLine === undefined || line < firstChangedLine) {
394
+ firstChangedLine = line;
395
+ }
396
+ };
397
+
398
+ // Partition: anchor-scoped vs file-scoped. Preserve original order via the
399
+ // captured idx so multiple pre/post on the same target are emitted in the order
400
+ // the model produced them.
401
+ type Indexed<T> = { edit: T; idx: number };
402
+ type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>;
403
+ const anchorEdits: Indexed<AnchorEdit>[] = [];
404
+ const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
405
+ const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
406
+ edits.forEach((edit, idx) => {
407
+ if (edit.op === "append_file") appendEdits.push({ edit, idx });
408
+ else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
409
+ else anchorEdits.push({ edit, idx });
410
+ });
411
+
412
+ // Group anchor edits by line so all ops on the same line are applied as a
413
+ // single splice. This makes the per-anchor outcome independent of index
414
+ // shifts caused by sibling ops (e.g. `post` paired with `del` on the same
415
+ // anchor, or repeated `pre`/`post` inserts that previously reversed).
416
+ const byLine = new Map<number, Indexed<AnchorEdit>[]>();
417
+ for (const entry of anchorEdits) {
418
+ const line = entry.edit.pos.line;
419
+ let bucket = byLine.get(line);
420
+ if (!bucket) {
421
+ bucket = [];
422
+ byLine.set(line, bucket);
423
+ }
424
+ bucket.push(entry);
425
+ }
426
+
427
+ const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
428
+ for (const line of anchorLines) {
429
+ const bucket = byLine.get(line);
430
+ if (!bucket) continue;
431
+ bucket.sort((a, b) => a.idx - b.idx);
432
+
433
+ const idx = line - 1;
434
+ const currentLine = fileLines[idx];
435
+ let replacement: string[] = [currentLine];
436
+ let replacementSet = false;
437
+ let anchorMutated = false;
438
+ let anchorDeleted = false;
439
+ const beforeLines: string[] = [];
440
+ const afterLines: string[] = [];
441
+
442
+ for (const { edit } of bucket) {
443
+ switch (edit.op) {
444
+ case "pre":
445
+ beforeLines.push(...edit.lines);
446
+ break;
447
+ case "post":
448
+ afterLines.push(...edit.lines);
449
+ break;
450
+ case "del":
451
+ replacement = [];
452
+ replacementSet = true;
453
+ anchorDeleted = true;
454
+ break;
455
+ case "set":
456
+ replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
457
+ replacementSet = true;
458
+ anchorMutated = true;
459
+ break;
460
+ }
461
+ }
462
+
463
+ const noOp = !replacementSet && beforeLines.length === 0 && afterLines.length === 0;
464
+ if (noOp) continue;
465
+
466
+ const originalLine = fileLines[idx];
467
+ const replacementProducesNoChange =
468
+ beforeLines.length === 0 &&
469
+ afterLines.length === 0 &&
470
+ replacement.length === 1 &&
471
+ replacement[0] === originalLine;
472
+ if (replacementProducesNoChange) {
473
+ const firstEdit = bucket[0]?.edit;
474
+ const loc = firstEdit ? `${firstEdit.pos.line}${firstEdit.pos.hash}` : `${line}`;
475
+ const reason = "replacement is identical to the current line content";
476
+ noopEdits.push({
477
+ editIndex: bucket[0]?.idx ?? 0,
478
+ loc,
479
+ reason,
480
+ current: originalLine,
481
+ });
482
+ continue;
483
+ }
484
+
485
+ const combined = [...beforeLines, ...replacement, ...afterLines];
486
+ fileLines.splice(idx, 1, ...combined);
487
+
488
+ if (beforeLines.length > 0 || anchorMutated || anchorDeleted) {
489
+ trackFirstChanged(line);
490
+ } else if (afterLines.length > 0) {
491
+ trackFirstChanged(line + 1);
492
+ }
493
+ }
494
+
495
+ // Apply prepend_file ops in original order so the first one ends up at the
496
+ // very top of the file.
497
+ prependEdits.sort((a, b) => a.idx - b.idx);
498
+ for (const { edit } of prependEdits) {
499
+ if (edit.lines.length === 0) continue;
500
+ if (fileLines.length === 1 && fileLines[0] === "") {
501
+ fileLines.splice(0, 1, ...edit.lines);
502
+ } else {
503
+ // Insert in reverse cumulative order so later splices push earlier
504
+ // content further down, preserving the original op order.
505
+ fileLines.splice(0, 0, ...edit.lines);
506
+ }
507
+ trackFirstChanged(1);
508
+ }
509
+
510
+ // Apply append_file ops in original order. When the file ends with a
511
+ // trailing newline (last split element is the empty sentinel), insert
512
+ // before that sentinel so the trailing newline is preserved.
513
+ appendEdits.sort((a, b) => a.idx - b.idx);
514
+ for (const { edit } of appendEdits) {
515
+ if (edit.lines.length === 0) continue;
516
+ if (fileLines.length === 1 && fileLines[0] === "") {
517
+ fileLines.splice(0, 1, ...edit.lines);
518
+ trackFirstChanged(1);
519
+ continue;
520
+ }
521
+ const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
522
+ const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
523
+ fileLines.splice(insertIdx, 0, ...edit.lines);
524
+ trackFirstChanged(insertIdx + 1);
525
+ }
526
+
527
+ return {
528
+ lines: fileLines.join("\n"),
529
+ firstChangedLine,
530
+ ...(warnings.length > 0 ? { warnings } : {}),
531
+ ...(noopEdits.length > 0 ? { noopEdits } : {}),
532
+ };
533
+ }
534
+
535
+ // ═══════════════════════════════════════════════════════════════════════════
536
+ // Executor
537
+ // ═══════════════════════════════════════════════════════════════════════════
538
+
539
+ export interface ExecuteAtomSingleOptions {
540
+ session: ToolSession;
541
+ path: string;
542
+ edits: AtomToolEdit[];
543
+ signal?: AbortSignal;
544
+ batchRequest?: LspBatchRequest;
545
+ writethrough: WritethroughCallback;
546
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
547
+ }
548
+
549
+ export async function executeAtomSingle(
550
+ options: ExecuteAtomSingleOptions,
551
+ ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
552
+ const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
553
+
554
+ const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i));
555
+
556
+ enforcePlanModeWrite(session, path, { op: "update" });
557
+
558
+ if (path.endsWith(".ipynb") && contentEdits.length > 0) {
559
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
560
+ }
561
+
562
+ const absolutePath = resolvePlanPath(session, path);
563
+
564
+ const sourceFile = Bun.file(absolutePath);
565
+ const sourceExists = await sourceFile.exists();
566
+
567
+ if (!sourceExists) {
568
+ const lines: string[] = [];
569
+ for (const edit of contentEdits) {
570
+ if (edit.op === "append_file") {
571
+ lines.push(...edit.lines);
572
+ } else if (edit.op === "prepend_file") {
573
+ lines.unshift(...edit.lines);
574
+ } else {
575
+ throw new Error(`File not found: ${path}`);
576
+ }
577
+ }
578
+
579
+ await Bun.write(absolutePath, lines.join("\n"));
580
+ invalidateFsScanAfterWrite(absolutePath);
581
+ return {
582
+ content: [{ type: "text", text: `Created ${path}` }],
583
+ details: {
584
+ diff: "",
585
+ op: "create",
586
+ meta: outputMeta().get(),
587
+ },
588
+ };
589
+ }
590
+
591
+ const rawContent = await sourceFile.text();
592
+ assertEditableFileContent(rawContent, path);
593
+
594
+ const { bom, text } = stripBom(rawContent);
595
+ const originalEnding = detectLineEnding(text);
596
+ const originalNormalized = normalizeToLF(text);
597
+
598
+ const result = applyAtomEdits(originalNormalized, contentEdits);
599
+ if (originalNormalized === result.lines) {
600
+ let diagnostic = `Edits to ${path} resulted in no changes being made.`;
601
+ if (result.noopEdits && result.noopEdits.length > 0) {
602
+ const details = result.noopEdits
603
+ .map(e => {
604
+ const preview =
605
+ e.current.length > 0
606
+ ? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
607
+ : "";
608
+ return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
609
+ })
610
+ .join("\n");
611
+ diagnostic += `\n${details}`;
612
+ }
613
+ throw new Error(diagnostic);
614
+ }
615
+
616
+ const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
617
+ const diagnostics = await writethrough(
618
+ absolutePath,
619
+ finalContent,
620
+ signal,
621
+ Bun.file(absolutePath),
622
+ batchRequest,
623
+ dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
624
+ );
625
+ invalidateFsScanAfterWrite(absolutePath);
626
+
627
+ const diffResult = generateDiffString(originalNormalized, result.lines);
628
+ const meta = outputMeta()
629
+ .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
630
+ .get();
631
+
632
+ const resultText = `Updated ${path}`;
633
+ const preview = buildCompactHashlineDiffPreview(diffResult.diff);
634
+ const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
635
+ preview.preview ? "" : " (no textual diff preview)"
636
+ }`;
637
+ const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
638
+ const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
639
+
640
+ return {
641
+ content: [
642
+ {
643
+ type: "text",
644
+ text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
645
+ },
646
+ ],
647
+ details: {
648
+ diff: diffResult.diff,
649
+ firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
650
+ diagnostics,
651
+ op: "update",
652
+ meta,
653
+ },
654
+ };
655
+ }
656
+
657
+ // Helpers exposed for tests / external dispatch.
658
+ export { classifyAtomEdit, parseAnchor, resolveAtomToolEdit };