@oh-my-pi/pi-coding-agent 14.3.0 → 14.4.1

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 (120) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/package.json +7 -7
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  5. package/src/config/model-registry.ts +67 -15
  6. package/src/config/prompt-templates.ts +5 -5
  7. package/src/config/settings-schema.ts +4 -4
  8. package/src/cursor.ts +3 -8
  9. package/src/discovery/helpers.ts +3 -3
  10. package/src/edit/diff.ts +50 -47
  11. package/src/edit/index.ts +86 -57
  12. package/src/edit/line-hash.ts +743 -24
  13. package/src/edit/modes/apply-patch.ts +0 -9
  14. package/src/edit/modes/atom.ts +893 -0
  15. package/src/edit/modes/chunk.ts +14 -24
  16. package/src/edit/modes/hashline.ts +193 -146
  17. package/src/edit/modes/patch.ts +5 -9
  18. package/src/edit/modes/replace.ts +6 -11
  19. package/src/edit/renderer.ts +14 -10
  20. package/src/edit/streaming.ts +50 -16
  21. package/src/exec/bash-executor.ts +2 -4
  22. package/src/export/html/template.generated.ts +1 -1
  23. package/src/export/html/template.js +4 -12
  24. package/src/extensibility/custom-tools/types.ts +2 -0
  25. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  26. package/src/internal-urls/docs-index.generated.ts +2 -2
  27. package/src/lsp/defaults.json +142 -652
  28. package/src/lsp/index.ts +1 -1
  29. package/src/mcp/render.ts +1 -8
  30. package/src/modes/components/assistant-message.ts +4 -0
  31. package/src/modes/components/diff.ts +23 -14
  32. package/src/modes/components/footer.ts +21 -16
  33. package/src/modes/components/session-selector.ts +3 -3
  34. package/src/modes/components/settings-defs.ts +6 -1
  35. package/src/modes/components/todo-reminder.ts +1 -8
  36. package/src/modes/components/tool-execution.ts +1 -4
  37. package/src/modes/controllers/selector-controller.ts +1 -1
  38. package/src/modes/print-mode.ts +8 -0
  39. package/src/prompts/agents/librarian.md +1 -1
  40. package/src/prompts/agents/reviewer.md +4 -4
  41. package/src/prompts/ci-green-request.md +1 -1
  42. package/src/prompts/review-request.md +1 -1
  43. package/src/prompts/system/subagent-system-prompt.md +3 -3
  44. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  45. package/src/prompts/system/system-prompt.md +3 -0
  46. package/src/prompts/tools/ask.md +3 -2
  47. package/src/prompts/tools/ast-edit.md +16 -20
  48. package/src/prompts/tools/ast-grep.md +19 -24
  49. package/src/prompts/tools/atom.md +87 -0
  50. package/src/prompts/tools/chunk-edit.md +37 -161
  51. package/src/prompts/tools/debug.md +4 -5
  52. package/src/prompts/tools/exit-plan-mode.md +4 -5
  53. package/src/prompts/tools/find.md +4 -8
  54. package/src/prompts/tools/github.md +18 -0
  55. package/src/prompts/tools/grep.md +4 -5
  56. package/src/prompts/tools/hashline.md +22 -89
  57. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  58. package/src/prompts/tools/inspect-image.md +6 -6
  59. package/src/prompts/tools/lsp.md +1 -1
  60. package/src/prompts/tools/patch.md +12 -19
  61. package/src/prompts/tools/python.md +3 -2
  62. package/src/prompts/tools/read-chunk.md +2 -3
  63. package/src/prompts/tools/read.md +2 -2
  64. package/src/prompts/tools/ssh.md +8 -17
  65. package/src/prompts/tools/todo-write.md +54 -41
  66. package/src/sdk.ts +14 -9
  67. package/src/session/agent-session.ts +25 -2
  68. package/src/session/session-manager.ts +4 -1
  69. package/src/task/executor.ts +43 -48
  70. package/src/task/render.ts +11 -13
  71. package/src/tools/ask.ts +7 -7
  72. package/src/tools/ast-edit.ts +45 -41
  73. package/src/tools/ast-grep.ts +77 -85
  74. package/src/tools/bash.ts +8 -9
  75. package/src/tools/browser.ts +32 -30
  76. package/src/tools/calculator.ts +4 -4
  77. package/src/tools/cancel-job.ts +1 -1
  78. package/src/tools/checkpoint.ts +2 -2
  79. package/src/tools/debug.ts +41 -37
  80. package/src/tools/exit-plan-mode.ts +1 -1
  81. package/src/tools/find.ts +4 -4
  82. package/src/tools/gh-renderer.ts +12 -4
  83. package/src/tools/gh.ts +509 -697
  84. package/src/tools/grep.ts +116 -131
  85. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  86. package/src/tools/index.ts +14 -32
  87. package/src/tools/inspect-image.ts +3 -3
  88. package/src/tools/json-tree.ts +114 -114
  89. package/src/tools/match-line-format.ts +8 -7
  90. package/src/tools/notebook.ts +8 -7
  91. package/src/tools/poll-tool.ts +2 -1
  92. package/src/tools/python.ts +9 -23
  93. package/src/tools/read.ts +32 -25
  94. package/src/tools/render-mermaid.ts +1 -1
  95. package/src/tools/render-utils.ts +18 -0
  96. package/src/tools/renderers.ts +2 -2
  97. package/src/tools/report-tool-issue.ts +3 -2
  98. package/src/tools/resolve.ts +1 -1
  99. package/src/tools/review.ts +12 -10
  100. package/src/tools/search-tool-bm25.ts +2 -4
  101. package/src/tools/ssh.ts +4 -4
  102. package/src/tools/todo-write.ts +172 -147
  103. package/src/tools/vim.ts +14 -15
  104. package/src/tools/write.ts +4 -4
  105. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  106. package/src/utils/edit-mode.ts +2 -1
  107. package/src/utils/file-display-mode.ts +10 -5
  108. package/src/utils/git.ts +9 -5
  109. package/src/utils/shell-snapshot.ts +2 -3
  110. package/src/vim/render.ts +4 -4
  111. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  112. package/src/prompts/tools/gh-issue-view.md +0 -11
  113. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  114. package/src/prompts/tools/gh-pr-diff.md +0 -12
  115. package/src/prompts/tools/gh-pr-push.md +0 -12
  116. package/src/prompts/tools/gh-pr-view.md +0 -11
  117. package/src/prompts/tools/gh-repo-view.md +0 -11
  118. package/src/prompts/tools/gh-run-watch.md +0 -12
  119. package/src/prompts/tools/gh-search-issues.md +0 -11
  120. package/src/prompts/tools/gh-search-prs.md +0 -11
@@ -0,0 +1,893 @@
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, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } 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
+ sed: Type.Optional(
69
+ Type.String({
70
+ description: "sed-style substitution applied to the anchored line",
71
+ examples: ["s/foo/bar/", "s|api|API|g", "s/<pat>/<rep>/F"],
72
+ }),
73
+ ),
74
+ },
75
+ { additionalProperties: false },
76
+ );
77
+
78
+ export const atomEditParamsSchema = Type.Object(
79
+ {
80
+ path: Type.Optional(Type.String({ description: "default file path for edits" })),
81
+ edits: Type.Array(atomEditSchema, { description: "edit ops" }),
82
+ },
83
+ { additionalProperties: false },
84
+ );
85
+
86
+ export type AtomToolEdit = Static<typeof atomEditSchema>;
87
+ export type AtomParams = Static<typeof atomEditParamsSchema>;
88
+
89
+ // ═══════════════════════════════════════════════════════════════════════════
90
+ // Internal resolved op shapes
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+
93
+ export type AtomEdit =
94
+ | { op: "set"; pos: Anchor; lines: string[] }
95
+ | { op: "pre"; pos: Anchor; lines: string[] }
96
+ | { op: "post"; pos: Anchor; lines: string[] }
97
+ | { op: "del"; pos: Anchor }
98
+ | { op: "append_file"; lines: string[] }
99
+ | { op: "prepend_file"; lines: string[] }
100
+ | { op: "sed"; pos: Anchor; spec: SedSpec; expression: string };
101
+
102
+ export interface SedSpec {
103
+ pattern: string;
104
+ replacement: string;
105
+ global: boolean;
106
+ ignoreCase: boolean;
107
+ literal: boolean;
108
+ }
109
+
110
+ // ═══════════════════════════════════════════════════════════════════════════
111
+ // Param guards
112
+ // ═══════════════════════════════════════════════════════════════════════════
113
+
114
+ const ATOM_VERB_KEYS = ["set", "pre", "post", "sed"] as const;
115
+ type AtomOptionalKey = "path" | "loc" | (typeof ATOM_VERB_KEYS)[number];
116
+ const ATOM_OPTIONAL_KEYS = ["path", "loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
117
+
118
+ // Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
119
+ // optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
120
+ // captured as a content hint for anchor disambiguation.
121
+ const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
122
+
123
+ // Splits `path:loc` references where the right side starts with a valid anchor
124
+ // (single `\d+<bigram>` or `<anchor>-<anchor>` range, optionally followed by a
125
+ // content suffix using `|` or `:`). The non-greedy `(.+?)` picks the leftmost
126
+ // colon whose RHS is a real anchor, so colons inside the loc's content suffix
127
+ // (TS type annotations, etc.) don't break the split. Drive-letter prefixes like
128
+ // `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
129
+ // fails the anchor pattern.
130
+ const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
131
+ const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
132
+
133
+ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
134
+ let next: Record<string, unknown> | undefined;
135
+ const fields = edit as Record<string, unknown>;
136
+ for (const key of ATOM_OPTIONAL_KEYS) {
137
+ if (fields[key] !== null) continue;
138
+ next ??= { ...fields };
139
+ delete next[key];
140
+ }
141
+ return (next ?? fields) as AtomToolEdit;
142
+ }
143
+
144
+ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind: "eof" };
145
+
146
+ // ═══════════════════════════════════════════════════════════════════════════
147
+ // Resolution
148
+ // ═══════════════════════════════════════════════════════════════════════════
149
+
150
+ /**
151
+ * Parse an anchor reference like `"5th"`.
152
+ *
153
+ * Tolerant: on a malformed reference we still try to extract a 1-indexed line
154
+ * number from the leading digits so the validator can surface the *correct*
155
+ * `LINEHASH|content` for the user. The bogus hash is preserved in the returned
156
+ * anchor so the validator emits a content-rich mismatch error.
157
+ *
158
+ * If we cannot recover even a line number, throw a usage-style error with the
159
+ * raw reference quoted.
160
+ */
161
+ function parseAnchor(raw: string, opName: string): Anchor {
162
+ if (typeof raw !== "string" || raw.length === 0) {
163
+ throw new Error(`${opName} requires ${formatFullAnchorRequirement()}.`);
164
+ }
165
+ try {
166
+ return parseTag(raw);
167
+ } catch {
168
+ const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
169
+ if (lineMatch) {
170
+ const line = Number.parseInt(lineMatch[1], 10);
171
+ if (line >= 1) {
172
+ // Sentinel hash that will never match a real line, forcing the validator
173
+ // to report a mismatch with the actual hash + line content.
174
+ return { line, hash: "??" };
175
+ }
176
+ }
177
+ throw new Error(
178
+ `${opName} requires ${formatFullAnchorRequirement(raw)} Could not find a line number in the anchor.`,
179
+ );
180
+ }
181
+ }
182
+
183
+ function tryParseAtomTag(raw: string): Anchor | undefined {
184
+ try {
185
+ return parseTag(raw);
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ }
190
+
191
+ function resolveAtomEntryPath(
192
+ edit: AtomToolEdit,
193
+ topLevelPath: string | undefined,
194
+ editIndex: number,
195
+ ): AtomToolEdit & { path: string } {
196
+ const entry = stripNullAtomFields(edit);
197
+ let loc = entry.loc;
198
+ let pathOverride: string | undefined;
199
+ if (typeof loc === "string") {
200
+ const split = loc.match(PATH_LOC_SPLIT_RE);
201
+ if (split) {
202
+ pathOverride = split[1];
203
+ loc = split[2]!;
204
+ }
205
+ }
206
+ const path = pathOverride || entry.path || topLevelPath;
207
+ if (!path) {
208
+ throw new Error(
209
+ `Edit ${editIndex}: missing path. Provide a top-level path, per-entry path, or prefix loc with a file path (for example "a.ts:160sr").`,
210
+ );
211
+ }
212
+ return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
213
+ }
214
+
215
+ export function resolveAtomEntryPaths(
216
+ edits: readonly AtomToolEdit[],
217
+ topLevelPath: string | undefined,
218
+ ): (AtomToolEdit & { path: string })[] {
219
+ return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i));
220
+ }
221
+
222
+ function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
223
+ if (raw === "^") return { kind: "bof" };
224
+ if (raw === "$") return { kind: "eof" };
225
+ // Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
226
+ // loc (e.g. line content like `i--`) should not trigger the range error.
227
+ const dash = raw.indexOf("-");
228
+ if (dash > 0) {
229
+ const left = raw.slice(0, dash);
230
+ const right = raw.slice(dash + 1);
231
+ if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
232
+ throw new Error(
233
+ `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr", "^", or "$".`,
234
+ );
235
+ }
236
+ }
237
+ const pos = parseAnchor(raw, "loc");
238
+ // Capture an optional content suffix after the anchor: `82zu| for (...)`.
239
+ // The suffix acts as a hint for anchor disambiguation when the model's hash
240
+ // is wrong but the content reveals the intended line.
241
+ const hint = extractAnchorContentHint(raw);
242
+ if (hint !== undefined) {
243
+ pos.contentHint = hint;
244
+ }
245
+ return { kind: "anchor", pos };
246
+ }
247
+
248
+ function extractAnchorContentHint(raw: string): string | undefined {
249
+ const match = raw.match(ANCHOR_PREFIX_RE);
250
+ if (!match) return undefined;
251
+ const rest = raw.slice(match[0].length);
252
+ // Accept either the canonical `|` (HASHLINE_CONTENT_SEPARATOR) or the legacy
253
+ // `:` separator. Models trained on older docs still emit `82zu: for (...)`.
254
+ const sep = rest[0];
255
+ if (sep !== HASHLINE_CONTENT_SEPARATOR && sep !== ":") return undefined;
256
+ const hint = rest.slice(1);
257
+ if (hint.trim().length === 0) return undefined;
258
+ return hint;
259
+ }
260
+
261
+ function parseSedExpression(raw: string, editIndex: number): SedSpec {
262
+ if (typeof raw !== "string" || raw.length < 3) {
263
+ throw new Error(
264
+ `Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
265
+ );
266
+ }
267
+ // Tolerate a missing leading `s`: models occasionally emit `/foo/bar/` directly.
268
+ // As long as the first character is a valid delimiter, treat the expression as
269
+ // if `s` was prepended.
270
+ let bodyStart = 0;
271
+ if (raw[0] === "s") {
272
+ bodyStart = 1;
273
+ }
274
+ const delim = raw[bodyStart]!;
275
+ if (/[\sA-Za-z0-9\\]/.test(delim)) {
276
+ throw new Error(
277
+ `Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
278
+ );
279
+ }
280
+ const parts: [string, string] = ["", ""];
281
+ let bucket: 0 | 1 = 0;
282
+ let i = bodyStart + 1;
283
+ while (i < raw.length) {
284
+ const c = raw[i]!;
285
+ if (c === "\\" && raw[i + 1] === delim) {
286
+ parts[bucket] += delim;
287
+ i += 2;
288
+ continue;
289
+ }
290
+ if (c === delim) {
291
+ if (bucket === 0) {
292
+ bucket = 1;
293
+ i += 1;
294
+ continue;
295
+ }
296
+ i += 1;
297
+ break;
298
+ }
299
+ parts[bucket] += c;
300
+ i += 1;
301
+ }
302
+ if (bucket !== 1) {
303
+ throw new Error(
304
+ `Edit ${editIndex}: malformed sed expression ${JSON.stringify(raw)}. Expected three ${JSON.stringify(delim)} separators.`,
305
+ );
306
+ }
307
+ const flagsStr = raw.slice(i);
308
+ let global = false;
309
+ let ignoreCase = false;
310
+ let literal = false;
311
+ for (const f of flagsStr) {
312
+ if (f === "g") global = true;
313
+ else if (f === "i") ignoreCase = true;
314
+ else if (f === "F") literal = true;
315
+ else {
316
+ throw new Error(
317
+ `Edit ${editIndex}: unknown sed flag ${JSON.stringify(f)}. Supported flags: g (all), i (case-insensitive), F (literal).`,
318
+ );
319
+ }
320
+ }
321
+ if (parts[0] === "") {
322
+ throw new Error(`Edit ${editIndex}: sed expression has empty pattern.`);
323
+ }
324
+ return { pattern: parts[0], replacement: parts[1], global, ignoreCase, literal };
325
+ }
326
+
327
+ function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
328
+ const idx = currentLine.indexOf(spec.pattern);
329
+ if (idx === -1) return { result: currentLine, matched: false };
330
+ if (spec.global) {
331
+ return { result: currentLine.split(spec.pattern).join(spec.replacement), matched: true };
332
+ }
333
+ return {
334
+ result: currentLine.slice(0, idx) + spec.replacement + currentLine.slice(idx + spec.pattern.length),
335
+ matched: true,
336
+ };
337
+ }
338
+
339
+ function applySedToLine(
340
+ currentLine: string,
341
+ spec: SedSpec,
342
+ ): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
343
+ if (spec.literal) {
344
+ return applyLiteralSed(currentLine, spec);
345
+ }
346
+ let flags = "";
347
+ if (spec.global) flags += "g";
348
+ if (spec.ignoreCase) flags += "i";
349
+ let re: RegExp | undefined;
350
+ let compileError: string | undefined;
351
+ try {
352
+ re = new RegExp(spec.pattern, flags);
353
+ } catch (e) {
354
+ compileError = (e as Error).message;
355
+ }
356
+ if (re?.test(currentLine)) {
357
+ re.lastIndex = 0;
358
+ return { result: currentLine.replace(re, spec.replacement), matched: true };
359
+ }
360
+ // Fall back to literal substring match. Models frequently send sed patterns
361
+ // containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
362
+ // intend as literal code. Trying a literal match before reporting failure
363
+ // recovers the obvious intent without changing semantics for patterns that
364
+ // already match as regex.
365
+ const literal = applyLiteralSed(currentLine, spec);
366
+ if (literal.matched) {
367
+ return { ...literal, literalFallback: true };
368
+ }
369
+ if (compileError !== undefined) {
370
+ return { result: currentLine, matched: false, error: compileError };
371
+ }
372
+ return { result: currentLine, matched: false };
373
+ }
374
+
375
+ function classifyAtomEdit(edit: AtomToolEdit): string {
376
+ const entry = stripNullAtomFields(edit);
377
+ const verbs = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
378
+ return verbs.length > 0 ? verbs.join("+") : "unknown";
379
+ }
380
+
381
+ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
382
+ const entry = stripNullAtomFields(edit);
383
+ const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
384
+ if (verbKeysPresent.length === 0) {
385
+ throw new Error(
386
+ `Edit ${editIndex}: missing verb. Each entry must include at least one of: ${ATOM_VERB_KEYS.join(", ")}.`,
387
+ );
388
+ }
389
+ if (typeof entry.loc !== "string") {
390
+ throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr", "^", or "$".`);
391
+ }
392
+
393
+ const loc = parseLoc(entry.loc, editIndex);
394
+ const resolved: AtomEdit[] = [];
395
+
396
+ if (loc.kind === "bof") {
397
+ if (entry.set !== undefined || entry.post !== undefined || entry.sed !== undefined) {
398
+ throw new Error(`Edit ${editIndex}: loc "^" only supports pre.`);
399
+ }
400
+ if (entry.pre !== undefined) {
401
+ resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
402
+ }
403
+ return resolved;
404
+ }
405
+
406
+ if (loc.kind === "eof") {
407
+ if (entry.set !== undefined || entry.pre !== undefined || entry.sed !== undefined) {
408
+ throw new Error(`Edit ${editIndex}: loc "$" only supports post.`);
409
+ }
410
+ if (entry.post !== undefined) {
411
+ resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
412
+ }
413
+ return resolved;
414
+ }
415
+
416
+ if (entry.pre !== undefined) {
417
+ resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
418
+ }
419
+ if (entry.set !== undefined) {
420
+ if (Array.isArray(entry.set) && entry.set.length === 0) {
421
+ // Models often default `set: []` alongside other verbs (notably `sed`).
422
+ // Treating that combination as an explicit `del` produces a confusing
423
+ // `Conflicting ops` error. When another mutating verb is present, drop
424
+ // the empty `set` instead of treating it as a deletion.
425
+ if (entry.sed === undefined) {
426
+ resolved.push({ op: "del", pos: loc.pos });
427
+ }
428
+ } else {
429
+ resolved.push({ op: "set", pos: loc.pos, lines: hashlineParseText(entry.set) });
430
+ }
431
+ }
432
+ if (entry.post !== undefined) {
433
+ resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
434
+ }
435
+ if (entry.sed !== undefined) {
436
+ const setIsExplicitReplacement = Array.isArray(entry.set) && entry.set.length > 0;
437
+ // Models often duplicate intent by sending both an explicit `set` and a
438
+ // matching `sed`. The explicit replacement wins; the redundant `sed` would
439
+ // otherwise trigger a confusing `Conflicting ops` rejection.
440
+ if (!setIsExplicitReplacement) {
441
+ const spec = parseSedExpression(entry.sed, editIndex);
442
+ resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
443
+ }
444
+ }
445
+ return resolved;
446
+ }
447
+
448
+ // ═══════════════════════════════════════════════════════════════════════════
449
+ // Validation
450
+ // ═══════════════════════════════════════════════════════════════════════════
451
+
452
+ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
453
+ switch (edit.op) {
454
+ case "set":
455
+ case "pre":
456
+ case "post":
457
+ case "del":
458
+ case "sed":
459
+ yield edit.pos;
460
+ return;
461
+ default:
462
+ return;
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Search for a line near `anchor.line` whose trimmed content equals the
468
+ * anchor's content hint. Returns the closest match (preferring lines below the
469
+ * requested anchor on ties) or `null` when no line matches. Strict equality on
470
+ * trimmed content keeps this conservative \u2014 we only retarget when there is no
471
+ * ambiguity about the model's intent.
472
+ */
473
+ function findLineByContentHint(anchor: Anchor, fileLines: string[]): number | null {
474
+ const hint = anchor.contentHint?.trim();
475
+ if (!hint) return null;
476
+ const lo = Math.max(1, anchor.line - ANCHOR_REBASE_WINDOW);
477
+ const hi = Math.min(fileLines.length, anchor.line + ANCHOR_REBASE_WINDOW);
478
+ let best: { line: number; distance: number } | null = null;
479
+ for (let line = lo; line <= hi; line++) {
480
+ if (fileLines[line - 1].trim() !== hint) continue;
481
+ const distance = Math.abs(line - anchor.line);
482
+ if (best === null || distance < best.distance) {
483
+ best = { line, distance };
484
+ }
485
+ }
486
+ return best?.line ?? null;
487
+ }
488
+
489
+ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
490
+ const mismatches: HashMismatch[] = [];
491
+ for (const edit of edits) {
492
+ for (const anchor of getAtomAnchors(edit)) {
493
+ if (anchor.line < 1 || anchor.line > fileLines.length) {
494
+ throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
495
+ }
496
+ const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
497
+ if (actualHash === anchor.hash) continue;
498
+ // When the model supplied a content hint after the anchor (e.g.
499
+ // `82zu| for (...)`), prefer rebasing to the line that actually matches
500
+ // that content. This avoids false positives from hash-only rebasing where
501
+ // a coincidentally matching hash on a nearby line silently retargets the
502
+ // edit to the wrong line.
503
+ const hinted = findLineByContentHint(anchor, fileLines);
504
+ if (hinted !== null) {
505
+ const original = `${anchor.line}${anchor.hash}`;
506
+ const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
507
+ anchor.line = hinted;
508
+ anchor.hash = hintedHash;
509
+ warnings.push(
510
+ `Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
511
+ );
512
+ continue;
513
+ }
514
+ const rebased = tryRebaseAnchor(anchor, fileLines);
515
+ if (rebased !== null) {
516
+ const original = `${anchor.line}${anchor.hash}`;
517
+ anchor.line = rebased;
518
+ warnings.push(
519
+ `Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
520
+ );
521
+ continue;
522
+ }
523
+ mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
524
+ }
525
+ }
526
+ return mismatches;
527
+ }
528
+
529
+ function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
530
+ // For each anchor line, at most one mutating op (set/del).
531
+ // `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
532
+ const mutatingPerLine = new Map<number, string>();
533
+ for (const edit of edits) {
534
+ if (edit.op !== "set" && edit.op !== "del" && edit.op !== "sed") continue;
535
+ const existing = mutatingPerLine.get(edit.pos.line);
536
+ if (existing) {
537
+ throw new Error(
538
+ `Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
539
+ `At most one of set/del/sed is allowed per anchor.`,
540
+ );
541
+ }
542
+ mutatingPerLine.set(edit.pos.line, edit.op);
543
+ }
544
+ }
545
+
546
+ // ═══════════════════════════════════════════════════════════════════════════
547
+ // Apply
548
+ // ═══════════════════════════════════════════════════════════════════════════
549
+
550
+ function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
551
+ const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
552
+ if (!enabled) return;
553
+ for (const edit of edits) {
554
+ if (edit.op !== "set" && edit.op !== "pre" && edit.op !== "post") continue;
555
+ if (edit.lines.length === 0) continue;
556
+ const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
557
+ if (!hasEscapedTabs) continue;
558
+ const hasRealTabs = edit.lines.some(line => line.includes("\t"));
559
+ if (hasRealTabs) continue;
560
+ let correctedCount = 0;
561
+ const corrected = edit.lines.map(line =>
562
+ line.replace(/^((?:\\t)+)/, escaped => {
563
+ correctedCount += escaped.length / 2;
564
+ return "\t".repeat(escaped.length / 2);
565
+ }),
566
+ );
567
+ if (correctedCount === 0) continue;
568
+ edit.lines = corrected;
569
+ warnings.push(
570
+ `Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
571
+ );
572
+ }
573
+ }
574
+
575
+ export interface AtomNoopEdit {
576
+ editIndex: number;
577
+ loc: string;
578
+ reason: string;
579
+ current: string;
580
+ }
581
+
582
+ export function applyAtomEdits(
583
+ text: string,
584
+ edits: AtomEdit[],
585
+ ): {
586
+ lines: string;
587
+ firstChangedLine: number | undefined;
588
+ warnings?: string[];
589
+ noopEdits?: AtomNoopEdit[];
590
+ } {
591
+ if (edits.length === 0) {
592
+ return { lines: text, firstChangedLine: undefined };
593
+ }
594
+
595
+ const fileLines = text.split("\n");
596
+ const warnings: string[] = [];
597
+ let firstChangedLine: number | undefined;
598
+ const noopEdits: AtomNoopEdit[] = [];
599
+
600
+ const mismatches = validateAtomAnchors(edits, fileLines, warnings);
601
+ if (mismatches.length > 0) {
602
+ throw new HashlineMismatchError(mismatches, fileLines);
603
+ }
604
+ validateNoConflictingAnchorOps(edits);
605
+ maybeAutocorrectEscapedTabIndentation(edits, warnings);
606
+
607
+ const trackFirstChanged = (line: number) => {
608
+ if (firstChangedLine === undefined || line < firstChangedLine) {
609
+ firstChangedLine = line;
610
+ }
611
+ };
612
+
613
+ // Partition: anchor-scoped vs file-scoped. Preserve original order via the
614
+ // captured idx so multiple pre/post on the same target are emitted in the order
615
+ // the model produced them.
616
+ type Indexed<T> = { edit: T; idx: number };
617
+ type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>;
618
+ const anchorEdits: Indexed<AnchorEdit>[] = [];
619
+ const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
620
+ const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
621
+ edits.forEach((edit, idx) => {
622
+ if (edit.op === "append_file") appendEdits.push({ edit, idx });
623
+ else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
624
+ else anchorEdits.push({ edit, idx });
625
+ });
626
+
627
+ // Group anchor edits by line so all ops on the same line are applied as a
628
+ // single splice. This makes the per-anchor outcome independent of index
629
+ // shifts caused by sibling ops (e.g. `post` paired with `del` on the same
630
+ // anchor, or repeated `pre`/`post` inserts that previously reversed).
631
+ const byLine = new Map<number, Indexed<AnchorEdit>[]>();
632
+ for (const entry of anchorEdits) {
633
+ const line = entry.edit.pos.line;
634
+ let bucket = byLine.get(line);
635
+ if (!bucket) {
636
+ bucket = [];
637
+ byLine.set(line, bucket);
638
+ }
639
+ bucket.push(entry);
640
+ }
641
+
642
+ const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
643
+ for (const line of anchorLines) {
644
+ const bucket = byLine.get(line);
645
+ if (!bucket) continue;
646
+ bucket.sort((a, b) => a.idx - b.idx);
647
+
648
+ const idx = line - 1;
649
+ const currentLine = fileLines[idx];
650
+ let replacement: string[] = [currentLine];
651
+ let replacementSet = false;
652
+ let anchorMutated = false;
653
+ let anchorDeleted = false;
654
+ const beforeLines: string[] = [];
655
+ const afterLines: string[] = [];
656
+
657
+ for (const { edit } of bucket) {
658
+ switch (edit.op) {
659
+ case "pre":
660
+ beforeLines.push(...edit.lines);
661
+ break;
662
+ case "post":
663
+ afterLines.push(...edit.lines);
664
+ break;
665
+ case "del":
666
+ replacement = [];
667
+ replacementSet = true;
668
+ anchorDeleted = true;
669
+ break;
670
+ case "set":
671
+ replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
672
+ replacementSet = true;
673
+ anchorMutated = true;
674
+ break;
675
+ case "sed": {
676
+ const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
677
+ if (error) {
678
+ throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${error}`);
679
+ }
680
+ if (!matched) {
681
+ throw new Error(
682
+ `Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(currentLine)}`,
683
+ );
684
+ }
685
+ if (literalFallback) {
686
+ warnings.push(
687
+ `sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
688
+ );
689
+ }
690
+ replacement = [result];
691
+ replacementSet = true;
692
+ anchorMutated = true;
693
+ break;
694
+ }
695
+ }
696
+ }
697
+
698
+ const noOp = !replacementSet && beforeLines.length === 0 && afterLines.length === 0;
699
+ if (noOp) continue;
700
+
701
+ const originalLine = fileLines[idx];
702
+ const replacementProducesNoChange =
703
+ beforeLines.length === 0 &&
704
+ afterLines.length === 0 &&
705
+ replacement.length === 1 &&
706
+ replacement[0] === originalLine;
707
+ if (replacementProducesNoChange) {
708
+ const firstEdit = bucket[0]?.edit;
709
+ const loc = firstEdit ? `${firstEdit.pos.line}${firstEdit.pos.hash}` : `${line}`;
710
+ const reason = "replacement is identical to the current line content";
711
+ noopEdits.push({
712
+ editIndex: bucket[0]?.idx ?? 0,
713
+ loc,
714
+ reason,
715
+ current: originalLine,
716
+ });
717
+ continue;
718
+ }
719
+
720
+ const combined = [...beforeLines, ...replacement, ...afterLines];
721
+ fileLines.splice(idx, 1, ...combined);
722
+
723
+ if (beforeLines.length > 0 || anchorMutated || anchorDeleted) {
724
+ trackFirstChanged(line);
725
+ } else if (afterLines.length > 0) {
726
+ trackFirstChanged(line + 1);
727
+ }
728
+ }
729
+
730
+ // Apply prepend_file ops in original order so the first one ends up at the
731
+ // very top of the file.
732
+ prependEdits.sort((a, b) => a.idx - b.idx);
733
+ for (const { edit } of prependEdits) {
734
+ if (edit.lines.length === 0) continue;
735
+ if (fileLines.length === 1 && fileLines[0] === "") {
736
+ fileLines.splice(0, 1, ...edit.lines);
737
+ } else {
738
+ // Insert in reverse cumulative order so later splices push earlier
739
+ // content further down, preserving the original op order.
740
+ fileLines.splice(0, 0, ...edit.lines);
741
+ }
742
+ trackFirstChanged(1);
743
+ }
744
+
745
+ // Apply append_file ops in original order. When the file ends with a
746
+ // trailing newline (last split element is the empty sentinel), insert
747
+ // before that sentinel so the trailing newline is preserved.
748
+ appendEdits.sort((a, b) => a.idx - b.idx);
749
+ for (const { edit } of appendEdits) {
750
+ if (edit.lines.length === 0) continue;
751
+ if (fileLines.length === 1 && fileLines[0] === "") {
752
+ fileLines.splice(0, 1, ...edit.lines);
753
+ trackFirstChanged(1);
754
+ continue;
755
+ }
756
+ const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
757
+ const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
758
+ fileLines.splice(insertIdx, 0, ...edit.lines);
759
+ trackFirstChanged(insertIdx + 1);
760
+ }
761
+
762
+ return {
763
+ lines: fileLines.join("\n"),
764
+ firstChangedLine,
765
+ ...(warnings.length > 0 ? { warnings } : {}),
766
+ ...(noopEdits.length > 0 ? { noopEdits } : {}),
767
+ };
768
+ }
769
+
770
+ // ═══════════════════════════════════════════════════════════════════════════
771
+ // Executor
772
+ // ═══════════════════════════════════════════════════════════════════════════
773
+
774
+ export interface ExecuteAtomSingleOptions {
775
+ session: ToolSession;
776
+ path: string;
777
+ edits: AtomToolEdit[];
778
+ signal?: AbortSignal;
779
+ batchRequest?: LspBatchRequest;
780
+ writethrough: WritethroughCallback;
781
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
782
+ }
783
+
784
+ export async function executeAtomSingle(
785
+ options: ExecuteAtomSingleOptions,
786
+ ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
787
+ const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
788
+
789
+ const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i));
790
+
791
+ enforcePlanModeWrite(session, path, { op: "update" });
792
+
793
+ if (path.endsWith(".ipynb") && contentEdits.length > 0) {
794
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
795
+ }
796
+
797
+ const absolutePath = resolvePlanPath(session, path);
798
+
799
+ const sourceFile = Bun.file(absolutePath);
800
+ const sourceExists = await sourceFile.exists();
801
+
802
+ if (!sourceExists) {
803
+ const lines: string[] = [];
804
+ for (const edit of contentEdits) {
805
+ if (edit.op === "append_file") {
806
+ lines.push(...edit.lines);
807
+ } else if (edit.op === "prepend_file") {
808
+ lines.unshift(...edit.lines);
809
+ } else {
810
+ throw new Error(`File not found: ${path}`);
811
+ }
812
+ }
813
+
814
+ await Bun.write(absolutePath, lines.join("\n"));
815
+ invalidateFsScanAfterWrite(absolutePath);
816
+ return {
817
+ content: [{ type: "text", text: `Created ${path}` }],
818
+ details: {
819
+ diff: "",
820
+ op: "create",
821
+ meta: outputMeta().get(),
822
+ },
823
+ };
824
+ }
825
+
826
+ const rawContent = await sourceFile.text();
827
+ assertEditableFileContent(rawContent, path);
828
+
829
+ const { bom, text } = stripBom(rawContent);
830
+ const originalEnding = detectLineEnding(text);
831
+ const originalNormalized = normalizeToLF(text);
832
+
833
+ const result = applyAtomEdits(originalNormalized, contentEdits);
834
+ if (originalNormalized === result.lines) {
835
+ let diagnostic = `Edits to ${path} resulted in no changes being made.`;
836
+ if (result.noopEdits && result.noopEdits.length > 0) {
837
+ const details = result.noopEdits
838
+ .map(e => {
839
+ const preview =
840
+ e.current.length > 0
841
+ ? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
842
+ : "";
843
+ return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
844
+ })
845
+ .join("\n");
846
+ diagnostic += `\n${details}`;
847
+ }
848
+ throw new Error(diagnostic);
849
+ }
850
+
851
+ const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
852
+ const diagnostics = await writethrough(
853
+ absolutePath,
854
+ finalContent,
855
+ signal,
856
+ Bun.file(absolutePath),
857
+ batchRequest,
858
+ dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
859
+ );
860
+ invalidateFsScanAfterWrite(absolutePath);
861
+
862
+ const diffResult = generateDiffString(originalNormalized, result.lines);
863
+ const meta = outputMeta()
864
+ .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
865
+ .get();
866
+
867
+ const resultText = `Updated ${path}`;
868
+ const preview = buildCompactHashlineDiffPreview(diffResult.diff);
869
+ const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
870
+ preview.preview ? "" : " (no textual diff preview)"
871
+ }`;
872
+ const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
873
+ const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
874
+
875
+ return {
876
+ content: [
877
+ {
878
+ type: "text",
879
+ text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
880
+ },
881
+ ],
882
+ details: {
883
+ diff: diffResult.diff,
884
+ firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
885
+ diagnostics,
886
+ op: "update",
887
+ meta,
888
+ },
889
+ };
890
+ }
891
+
892
+ // Helpers exposed for tests / external dispatch.
893
+ export { classifyAtomEdit, parseAnchor, resolveAtomToolEdit };