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

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 (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +7 -7
  3. package/src/cli.ts +0 -1
  4. package/src/config/prompt-templates.ts +1 -31
  5. package/src/config/settings-schema.ts +27 -37
  6. package/src/config/settings.ts +1 -1
  7. package/src/edit/index.ts +1 -53
  8. package/src/edit/line-hash.ts +13 -63
  9. package/src/edit/modes/atom.ts +334 -64
  10. package/src/edit/modes/hashline.ts +19 -26
  11. package/src/edit/renderer.ts +6 -8
  12. package/src/edit/streaming.ts +90 -114
  13. package/src/export/html/template.generated.ts +1 -1
  14. package/src/export/html/template.js +10 -15
  15. package/src/internal-urls/docs-index.generated.ts +1 -2
  16. package/src/lsp/defaults.json +142 -652
  17. package/src/modes/components/session-selector.ts +3 -3
  18. package/src/modes/components/settings-defs.ts +0 -5
  19. package/src/modes/components/tool-execution.ts +2 -5
  20. package/src/modes/controllers/btw-controller.ts +17 -105
  21. package/src/modes/controllers/todo-command-controller.ts +537 -0
  22. package/src/modes/interactive-mode.ts +35 -9
  23. package/src/modes/types.ts +2 -0
  24. package/src/modes/utils/ui-helpers.ts +17 -0
  25. package/src/prompts/system/irc-incoming.md +8 -0
  26. package/src/prompts/system/subagent-system-prompt.md +8 -0
  27. package/src/prompts/tools/ast-edit.md +1 -1
  28. package/src/prompts/tools/ast-grep.md +1 -0
  29. package/src/prompts/tools/atom.md +55 -53
  30. package/src/prompts/tools/bash.md +2 -2
  31. package/src/prompts/tools/grep.md +2 -5
  32. package/src/prompts/tools/irc.md +49 -0
  33. package/src/prompts/tools/job.md +11 -0
  34. package/src/prompts/tools/read.md +12 -13
  35. package/src/prompts/tools/task.md +1 -1
  36. package/src/prompts/tools/todo-write.md +14 -5
  37. package/src/registry/agent-registry.ts +139 -0
  38. package/src/sdk.ts +35 -0
  39. package/src/session/agent-session.ts +217 -5
  40. package/src/session/session-manager.ts +4 -1
  41. package/src/session/streaming-output.ts +1 -1
  42. package/src/slash-commands/builtin-registry.ts +24 -0
  43. package/src/task/executor.ts +14 -0
  44. package/src/tools/bash.ts +1 -1
  45. package/src/tools/fetch.ts +18 -6
  46. package/src/tools/fs-cache-invalidation.ts +0 -5
  47. package/src/tools/grep.ts +5 -125
  48. package/src/tools/index.ts +12 -6
  49. package/src/tools/irc.ts +258 -0
  50. package/src/tools/job.ts +489 -0
  51. package/src/tools/match-line-format.ts +8 -7
  52. package/src/tools/output-meta.ts +1 -1
  53. package/src/tools/read.ts +37 -131
  54. package/src/tools/renderers.ts +2 -0
  55. package/src/tools/todo-write.ts +243 -12
  56. package/src/tools/write.ts +2 -2
  57. package/src/utils/edit-mode.ts +1 -2
  58. package/src/utils/file-display-mode.ts +0 -3
  59. package/src/cli/read-cli.ts +0 -67
  60. package/src/commands/read.ts +0 -33
  61. package/src/edit/modes/chunk.ts +0 -832
  62. package/src/prompts/tools/cancel-job.md +0 -5
  63. package/src/prompts/tools/chunk-edit.md +0 -158
  64. package/src/prompts/tools/poll.md +0 -5
  65. package/src/prompts/tools/read-chunk.md +0 -73
  66. package/src/tools/cancel-job.ts +0 -95
  67. package/src/tools/poll-tool.ts +0 -173
@@ -1,19 +1,20 @@
1
1
  /**
2
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`, `set`, `post`).
4
+ * one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`).
5
5
  * The runtime resolves those verbs into internal anchor-scoped edits and still
6
6
  * reuses hashline's staleness scheme (`computeLineHash`) verbatim.
7
7
  *
8
8
  * External shapes (one entry):
9
- * { path, loc: "5th", set: ["..."] }
9
+ * { path, loc: "5th", splice: ["..."] }
10
10
  * { path, loc: "5th", pre: ["..."] }
11
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
12
+ * { path, loc: "5th", pre: [...], splice: [...], post: [...] }
13
+ * { path, loc: "$", pre: [...] } // prepend to file
14
+ * { path, loc: "$", post: [...] } // append to file
15
+ * { path, loc: "$", sed: "s/foo/bar/" } // sed on every line
15
16
  *
16
- * `set: []` on a single-anchor locator deletes that line. `set:[""]` preserves
17
+ * `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
17
18
  * a blank line. Line ranges are not supported.
18
19
  * in the same entry.
19
20
  *
@@ -29,7 +30,7 @@ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
29
30
  import { outputMeta } from "../../tools/output-meta";
30
31
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
31
32
  import { generateDiffString } from "../diff";
32
- import { computeLineHash } from "../line-hash";
33
+ import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
33
34
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
34
35
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
35
36
  import {
@@ -57,14 +58,19 @@ const textSchema = Type.Array(Type.String());
57
58
  */
58
59
  export const atomEditSchema = Type.Object(
59
60
  {
60
- path: Type.Optional(Type.String({ description: "file path override", examples: ["src/foo.ts"] })),
61
61
  loc: Type.String({
62
- description: 'edit location: "1ab", "^", "$", or path override like "a.ts:1ab"',
63
- examples: ["1ab", "^", "$", "src/foo.ts:1ab"],
62
+ description: 'edit location: "1ab", "$", or path override like "a.ts:1ab"',
63
+ examples: ["1ab", "$", "src/foo.ts:1ab"],
64
64
  }),
65
- set: Type.Optional(textSchema),
65
+ splice: Type.Optional(textSchema),
66
66
  pre: Type.Optional(textSchema),
67
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
+ ),
68
74
  },
69
75
  { additionalProperties: false },
70
76
  );
@@ -85,20 +91,45 @@ export type AtomParams = Static<typeof atomEditParamsSchema>;
85
91
  // ═══════════════════════════════════════════════════════════════════════════
86
92
 
87
93
  export type AtomEdit =
88
- | { op: "set"; pos: Anchor; lines: string[] }
94
+ | { op: "splice"; pos: Anchor; lines: string[] }
89
95
  | { op: "pre"; pos: Anchor; lines: string[] }
90
96
  | { op: "post"; pos: Anchor; lines: string[] }
91
97
  | { op: "del"; pos: Anchor }
92
98
  | { op: "append_file"; lines: string[] }
93
- | { op: "prepend_file"; lines: string[] };
99
+ | { op: "prepend_file"; lines: string[] }
100
+ | { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
101
+ | { op: "sed_file"; spec: SedSpec; expression: string };
102
+
103
+ export interface SedSpec {
104
+ pattern: string;
105
+ replacement: string;
106
+ global: boolean;
107
+ ignoreCase: boolean;
108
+ literal: boolean;
109
+ }
94
110
 
95
111
  // ═══════════════════════════════════════════════════════════════════════════
96
112
  // Param guards
97
113
  // ═══════════════════════════════════════════════════════════════════════════
98
114
 
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[];
115
+ const ATOM_VERB_KEYS = ["splice", "pre", "post", "sed"] as const;
116
+ type AtomOptionalKey = "loc" | (typeof ATOM_VERB_KEYS)[number];
117
+ const ATOM_OPTIONAL_KEYS = ["loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
118
+
119
+ // Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
120
+ // optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
121
+ // captured as a content hint for anchor disambiguation.
122
+ const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
123
+
124
+ // Splits `path:loc` references where the right side starts with a valid anchor
125
+ // (single `\d+<bigram>` or `<anchor>-<anchor>` range, optionally followed by a
126
+ // content suffix using `|` or `:`). The non-greedy `(.+?)` picks the leftmost
127
+ // colon whose RHS is a real anchor, so colons inside the loc's content suffix
128
+ // (TS type annotations, etc.) don't break the split. Drive-letter prefixes like
129
+ // `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
130
+ // fails the anchor pattern.
131
+ const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
132
+ const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
102
133
 
103
134
  function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
104
135
  let next: Record<string, unknown> | undefined;
@@ -111,7 +142,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
111
142
  return (next ?? fields) as AtomToolEdit;
112
143
  }
113
144
 
114
- type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind: "eof" };
145
+ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
115
146
 
116
147
  // ═══════════════════════════════════════════════════════════════════════════
117
148
  // Resolution
@@ -122,7 +153,7 @@ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind:
122
153
  *
123
154
  * Tolerant: on a malformed reference we still try to extract a 1-indexed line
124
155
  * 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
156
+ * `LINEHASH|content` for the user. The bogus hash is preserved in the returned
126
157
  * anchor so the validator emits a content-rich mismatch error.
127
158
  *
128
159
  * If we cannot recover even a line number, throw a usage-style error with the
@@ -158,16 +189,6 @@ function tryParseAtomTag(raw: string): Anchor | undefined {
158
189
  }
159
190
  }
160
191
 
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
192
  function resolveAtomEntryPath(
172
193
  edit: AtomToolEdit,
173
194
  topLevelPath: string | undefined,
@@ -177,19 +198,16 @@ function resolveAtomEntryPath(
177
198
  let loc = entry.loc;
178
199
  let pathOverride: string | undefined;
179
200
  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
- }
201
+ const split = loc.match(PATH_LOC_SPLIT_RE);
202
+ if (split) {
203
+ pathOverride = split[1];
204
+ loc = split[2]!;
187
205
  }
188
206
  }
189
- const path = pathOverride || entry.path || topLevelPath;
207
+ const path = pathOverride || topLevelPath;
190
208
  if (!path) {
191
209
  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").`,
210
+ `Edit ${editIndex}: missing path. Provide a top-level path or prefix loc with a file path (for example "a.ts:160sr").`,
193
211
  );
194
212
  }
195
213
  return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
@@ -203,14 +221,155 @@ export function resolveAtomEntryPaths(
203
221
  }
204
222
 
205
223
  function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
206
- if (raw === "^") return { kind: "bof" };
207
- if (raw === "$") return { kind: "eof" };
208
- if (raw.includes("-")) {
224
+ if (raw === "$") return { kind: "file" };
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)) {
209
276
  throw new Error(
210
- `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr", "^", or "$".`,
277
+ `Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
211
278
  );
212
279
  }
213
- return { kind: "anchor", pos: parseAnchor(raw, "loc") };
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 };
214
373
  }
215
374
 
216
375
  function classifyAtomEdit(edit: AtomToolEdit): string {
@@ -228,45 +387,58 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
228
387
  );
229
388
  }
230
389
  if (typeof entry.loc !== "string") {
231
- throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr", "^", or "$".`);
390
+ throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr" or "$".`);
232
391
  }
233
392
 
234
393
  const loc = parseLoc(entry.loc, editIndex);
235
394
  const resolved: AtomEdit[] = [];
236
395
 
237
- if (loc.kind === "bof") {
238
- if (entry.set !== undefined || entry.post !== undefined) {
239
- throw new Error(`Edit ${editIndex}: loc "^" only supports pre.`);
396
+ if (loc.kind === "file") {
397
+ if (entry.splice !== undefined) {
398
+ throw new Error(`Edit ${editIndex}: loc "$" supports pre, post, and sed (not splice).`);
240
399
  }
241
400
  if (entry.pre !== undefined) {
242
401
  resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
243
402
  }
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
403
  if (entry.post !== undefined) {
252
404
  resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
253
405
  }
406
+ if (entry.sed !== undefined) {
407
+ const spec = parseSedExpression(entry.sed, editIndex);
408
+ resolved.push({ op: "sed_file", spec, expression: entry.sed });
409
+ }
254
410
  return resolved;
255
411
  }
256
412
 
257
413
  if (entry.pre !== undefined) {
258
414
  resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
259
415
  }
260
- if (entry.set !== undefined) {
261
- if (Array.isArray(entry.set) && entry.set.length === 0) {
262
- resolved.push({ op: "del", pos: loc.pos });
416
+ if (entry.splice !== undefined) {
417
+ if (Array.isArray(entry.splice) && entry.splice.length === 0) {
418
+ // Models often default `splice: []` alongside other verbs (notably `sed`).
419
+ // Treating that combination as an explicit `del` produces a confusing
420
+ // `Conflicting ops` error. When another mutating verb is present, drop
421
+ // the empty `splice` instead of treating it as a deletion.
422
+ if (entry.sed === undefined) {
423
+ resolved.push({ op: "del", pos: loc.pos });
424
+ }
263
425
  } else {
264
- resolved.push({ op: "set", pos: loc.pos, lines: hashlineParseText(entry.set) });
426
+ resolved.push({ op: "splice", pos: loc.pos, lines: hashlineParseText(entry.splice) });
265
427
  }
266
428
  }
267
429
  if (entry.post !== undefined) {
268
430
  resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
269
431
  }
432
+ if (entry.sed !== undefined) {
433
+ const spliceIsExplicitReplacement = Array.isArray(entry.splice) && entry.splice.length > 0;
434
+ // Models often duplicate intent by sending both an explicit `splice` and a
435
+ // matching `sed`. The explicit replacement wins; the redundant `sed` would
436
+ // otherwise trigger a confusing `Conflicting ops` rejection.
437
+ if (!spliceIsExplicitReplacement) {
438
+ const spec = parseSedExpression(entry.sed, editIndex);
439
+ resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
440
+ }
441
+ }
270
442
  return resolved;
271
443
  }
272
444
 
@@ -276,10 +448,11 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
276
448
 
277
449
  function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
278
450
  switch (edit.op) {
279
- case "set":
451
+ case "splice":
280
452
  case "pre":
281
453
  case "post":
282
454
  case "del":
455
+ case "sed":
283
456
  yield edit.pos;
284
457
  return;
285
458
  default:
@@ -287,6 +460,29 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
287
460
  }
288
461
  }
289
462
 
463
+ /**
464
+ * Search for a line near `anchor.line` whose trimmed content equals the
465
+ * anchor's content hint. Returns the closest match (preferring lines below the
466
+ * requested anchor on ties) or `null` when no line matches. Strict equality on
467
+ * trimmed content keeps this conservative \u2014 we only retarget when there is no
468
+ * ambiguity about the model's intent.
469
+ */
470
+ function findLineByContentHint(anchor: Anchor, fileLines: string[]): number | null {
471
+ const hint = anchor.contentHint?.trim();
472
+ if (!hint) return null;
473
+ const lo = Math.max(1, anchor.line - ANCHOR_REBASE_WINDOW);
474
+ const hi = Math.min(fileLines.length, anchor.line + ANCHOR_REBASE_WINDOW);
475
+ let best: { line: number; distance: number } | null = null;
476
+ for (let line = lo; line <= hi; line++) {
477
+ if (fileLines[line - 1].trim() !== hint) continue;
478
+ const distance = Math.abs(line - anchor.line);
479
+ if (best === null || distance < best.distance) {
480
+ best = { line, distance };
481
+ }
482
+ }
483
+ return best?.line ?? null;
484
+ }
485
+
290
486
  function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
291
487
  const mismatches: HashMismatch[] = [];
292
488
  for (const edit of edits) {
@@ -296,6 +492,22 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
296
492
  }
297
493
  const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
298
494
  if (actualHash === anchor.hash) continue;
495
+ // When the model supplied a content hint after the anchor (e.g.
496
+ // `82zu| for (...)`), prefer rebasing to the line that actually matches
497
+ // that content. This avoids false positives from hash-only rebasing where
498
+ // a coincidentally matching hash on a nearby line silently retargets the
499
+ // edit to the wrong line.
500
+ const hinted = findLineByContentHint(anchor, fileLines);
501
+ if (hinted !== null) {
502
+ const original = `${anchor.line}${anchor.hash}`;
503
+ const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
504
+ anchor.line = hinted;
505
+ anchor.hash = hintedHash;
506
+ warnings.push(
507
+ `Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
508
+ );
509
+ continue;
510
+ }
299
511
  const rebased = tryRebaseAnchor(anchor, fileLines);
300
512
  if (rebased !== null) {
301
513
  const original = `${anchor.line}${anchor.hash}`;
@@ -312,16 +524,16 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
312
524
  }
313
525
 
314
526
  function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
315
- // For each anchor line, at most one mutating op (set/del).
527
+ // For each anchor line, at most one mutating op (splice/del).
316
528
  // `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
317
529
  const mutatingPerLine = new Map<number, string>();
318
530
  for (const edit of edits) {
319
- if (edit.op !== "set" && edit.op !== "del") continue;
531
+ if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
320
532
  const existing = mutatingPerLine.get(edit.pos.line);
321
533
  if (existing) {
322
534
  throw new Error(
323
535
  `Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
324
- `At most one of set/del is allowed per anchor.`,
536
+ `At most one of splice/del/sed is allowed per anchor.`,
325
537
  );
326
538
  }
327
539
  mutatingPerLine.set(edit.pos.line, edit.op);
@@ -336,7 +548,7 @@ function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: stri
336
548
  const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
337
549
  if (!enabled) return;
338
550
  for (const edit of edits) {
339
- if (edit.op !== "set" && edit.op !== "pre" && edit.op !== "post") continue;
551
+ if (edit.op !== "splice" && edit.op !== "pre" && edit.op !== "post") continue;
340
552
  if (edit.lines.length === 0) continue;
341
553
  const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
342
554
  if (!hasEscapedTabs) continue;
@@ -399,13 +611,15 @@ export function applyAtomEdits(
399
611
  // captured idx so multiple pre/post on the same target are emitted in the order
400
612
  // the model produced them.
401
613
  type Indexed<T> = { edit: T; idx: number };
402
- type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>;
614
+ type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" } | { op: "sed_file" }>;
403
615
  const anchorEdits: Indexed<AnchorEdit>[] = [];
404
616
  const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
617
+ const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
405
618
  const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
406
619
  edits.forEach((edit, idx) => {
407
620
  if (edit.op === "append_file") appendEdits.push({ edit, idx });
408
621
  else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
622
+ else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
409
623
  else anchorEdits.push({ edit, idx });
410
624
  });
411
625
 
@@ -452,11 +666,31 @@ export function applyAtomEdits(
452
666
  replacementSet = true;
453
667
  anchorDeleted = true;
454
668
  break;
455
- case "set":
669
+ case "splice":
456
670
  replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
457
671
  replacementSet = true;
458
672
  anchorMutated = true;
459
673
  break;
674
+ case "sed": {
675
+ const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
676
+ if (error) {
677
+ throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${error}`);
678
+ }
679
+ if (!matched) {
680
+ throw new Error(
681
+ `Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(currentLine)}`,
682
+ );
683
+ }
684
+ if (literalFallback) {
685
+ warnings.push(
686
+ `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.`,
687
+ );
688
+ }
689
+ replacement = [result];
690
+ replacementSet = true;
691
+ anchorMutated = true;
692
+ break;
693
+ }
460
694
  }
461
695
  }
462
696
 
@@ -524,6 +758,42 @@ export function applyAtomEdits(
524
758
  trackFirstChanged(insertIdx + 1);
525
759
  }
526
760
 
761
+ // Apply sed_file ops last so they observe the post-anchor / post-prepend /
762
+ // post-append state of the file. Each op runs across every content line and
763
+ let warnedLiteralFallback = false;
764
+ sedFileEdits.sort((a, b) => a.idx - b.idx);
765
+ for (const { edit } of sedFileEdits) {
766
+ const hasTrailingNewline = fileLines.length > 1 && fileLines[fileLines.length - 1] === "";
767
+ const upper = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
768
+ let anyMatched = false;
769
+ let lastCompileError: string | undefined;
770
+ for (let i = 0; i < upper; i++) {
771
+ const line = fileLines[i] ?? "";
772
+ const r = applySedToLine(line, edit.spec);
773
+ if (r.error) lastCompileError = r.error;
774
+ if (!r.matched) continue;
775
+ anyMatched = true;
776
+ if (r.literalFallback && !warnedLiteralFallback) {
777
+ warnings.push(
778
+ `sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
779
+ );
780
+ warnedLiteralFallback = true;
781
+ }
782
+ if (r.result !== line) {
783
+ fileLines[i] = r.result;
784
+ trackFirstChanged(i + 1);
785
+ }
786
+ }
787
+ if (!anyMatched) {
788
+ if (lastCompileError !== undefined) {
789
+ throw new Error(
790
+ `Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${lastCompileError}`,
791
+ );
792
+ }
793
+ throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
794
+ }
795
+ }
796
+
527
797
  return {
528
798
  lines: fileLines.join("\n"),
529
799
  firstChangedLine,