@oh-my-pi/pi-coding-agent 15.5.1 → 15.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.3] - 2026-05-27
6
+ ### Breaking Changes
7
+
8
+ - Disallowed inline payload on hashline `↑`, `↓`, and `:` operations (including BOF/EOF inserts), requiring payload text to be supplied on standalone `+` continuation rows
9
+
10
+ ### Changed
11
+
12
+ - Warned when legacy inline `LINE:TEXT` lines are accepted as payload continuations only when inside a pending multi-line `A-B:` replacement
13
+
14
+ ## [15.5.2] - 2026-05-26
15
+ ### Breaking Changes
16
+
17
+ - Changed the hashline patch format so payload continuation lines now require a leading `+`, rejecting unprefixed multiline payload rows that were previously accepted as fallback payload text
18
+
19
+ ### Changed
20
+
21
+ - Changed hashline payload parsing so blank lines are only preserved when prefixed with `+`, so blank separator lines between operations are ignored unless explicitly marked
22
+ - Changed payload escaping so a line beginning with `+` is now represented as `++...` while the leading marker is stripped before writing
23
+ - Changed the default `task.simple` mode from `default` to `schema-free`, so task-call `schema` inputs are disabled by default while shared `context` and user prompt/session-defined output schemas remain available
24
+ - Changed `tools.approvalMode: yolo` to auto-approve tool calls even when a tool marks `override: true`; user `tools.approval.<tool>` policies (`allow`/`prompt`/`deny`) now remain the only controls for yolo mode.
25
+ - Changed the hashline edit executor to coalesce two consecutive `A-B:` ops on the identical range last-wins (the model painted a before/after pair) and append a warning, instead of throwing `anchor line X is already targeted by the :/! op on line Y`. Other overlap shapes (different ranges, `A-B:`+`!`, `!`+`!`) still throw.
26
+
27
+ ### Fixed
28
+
29
+ - Fixed nested replace parsing so line-anchored `N:` rows inside a pending `A-B:` replacement now trigger overlap errors instead of being silently folded into the replacement payload
30
+
5
31
  ## [15.5.1] - 2026-05-26
6
32
 
7
33
  ### Breaking Changes
@@ -2151,7 +2151,7 @@ export declare const SETTINGS_SCHEMA: {
2151
2151
  readonly ui: {
2152
2152
  readonly tab: "interaction";
2153
2153
  readonly label: "Tool Approval";
2154
- readonly description: "Default approval behaviour for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves every tier unless a tool declares a safety override. `tools.approval.<tool>` overrides are honored in every mode.";
2154
+ readonly description: "Default approval behaviour for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.";
2155
2155
  readonly options: readonly [{
2156
2156
  readonly value: "always-ask";
2157
2157
  readonly label: "Always ask";
@@ -2163,7 +2163,7 @@ export declare const SETTINGS_SCHEMA: {
2163
2163
  }, {
2164
2164
  readonly value: "yolo";
2165
2165
  readonly label: "Yolo";
2166
- readonly description: "Auto-approve read, write, and exec tools. Safety overrides declared by tools (for example critical bash patterns) still require confirmation.";
2166
+ readonly description: "Auto-approve read, write, and exec tools. User policy can still require confirmation or block calls.";
2167
2167
  }];
2168
2168
  };
2169
2169
  };
@@ -2765,7 +2765,7 @@ export declare const SETTINGS_SCHEMA: {
2765
2765
  readonly "task.simple": {
2766
2766
  readonly type: "enum";
2767
2767
  readonly values: readonly ["default", "schema-free", "independent"];
2768
- readonly default: "default";
2768
+ readonly default: "schema-free";
2769
2769
  readonly ui: {
2770
2770
  readonly tab: "tasks";
2771
2771
  readonly label: "Task Input Mode";
@@ -15,3 +15,34 @@ export declare const END_PATCH_MARKER = "*** End Patch";
15
15
  export declare const ABORT_MARKER = "*** Abort";
16
16
  /** Warning text appended to the tool result when ABORT_MARKER terminates parsing. */
17
17
  export declare const ABORT_WARNING = "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
18
+ /**
19
+ * Warning text appended when two consecutive `A-B:` ops on the exact same
20
+ * range get coalesced (model painted a before/after pair). The second op
21
+ * wins; the first op's payload is silently discarded.
22
+ */
23
+ export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range \u2014 the payload is the final desired content, never both old and new.";
24
+ /**
25
+ * Warning text appended when un-prefixed continuation lines are accepted as
26
+ * implicit payload (lenient legacy behavior). The model authored a multi-line
27
+ * replace without `+` prefixes; the parser accepted it because the lines did
28
+ * not classify as ops/headers/payloads, but the canonical syntax requires `+`
29
+ * on every continuation line after the op.
30
+ */
31
+ export declare const IMPLICIT_CONTINUATION_WARNING = "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
32
+ /**
33
+ * Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
34
+ * op arrives while an outer `A-B:` replace is still pending and the inner
35
+ * anchor falls inside the outer range. The model used the read-output
36
+ * `LINE:TEXT` format as if it were a payload-continuation line; we strip the
37
+ * `LINE:` prefix and append the body to the pending payload, but warn so the
38
+ * canonical `+`-continuation form remains preferred.
39
+ */
40
+ export declare const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING = "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` \u2014 never reuse the read-output gutter format.";
41
+ /**
42
+ * Warning text appended when an op carries an inline payload (`LINE:TEXT`,
43
+ * `A-B:TEXT`, `LINE↑TEXT`, `LINE↓TEXT`). Canonical syntax is bare op +
44
+ * `+`-prefixed continuation rows; we accept the inline form leniently so the
45
+ * model's first-attempt edit still lands, but warn so the canonical form
46
+ * remains preferred.
47
+ */
48
+ export declare const INLINE_PAYLOAD_ACCEPTED_WARNING = "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE\u2191CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
@@ -28,12 +28,13 @@ export declare class HashlineExecutor {
28
28
  */
29
29
  feed(token: HashlineToken): void;
30
30
  /**
31
- * Flush any open pending op (with its full accumulated payload, blanks
32
- * included) and return the accumulated edits and warnings. The executor
33
- * is single-use; reset() is required for reuse.
34
- * Throws if two replace/delete ops target the same line that pattern
35
- * means the diff is painting a before/after picture instead of stating
36
- * the final state, and applying both would silently duplicate content.
31
+ * Flush any open pending op (with its full accumulated payload, including
32
+ * explicit `+` blank lines) and return the accumulated edits and warnings.
33
+ * The executor is single-use; reset() is required for reuse.
34
+ * Throws if two replace/delete ops target the same line with non-identical
35
+ * shapes (different ranges, replace+delete, delete+delete). Identical-range
36
+ * `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
37
+ * warning, so they never reach the validator.
37
38
  */
38
39
  end(): {
39
40
  edits: HashlineEdit[];
@@ -4,14 +4,14 @@
4
4
  */
5
5
  /**
6
6
  * Decoration prefix that may precede a line number in tool output:
7
- * `>` (context line in grep), `+` (added line in diff), `-` (removed line),
8
- * `*` (match line). Any combination, in any order, surrounded by optional
7
+ * `>` (context line in grep), `-` (removed line), `*` (match line).
8
+ * Any combination, in any order, surrounded by optional
9
9
  * whitespace. Output formatters emit at most one decoration per line; the
10
10
  * parser stays liberal because it accepts whatever the model echoes back.
11
11
  */
12
- export declare const HL_ANCHOR_DECORATION_RE_RAW = "\\s*[>+\\-*]*\\s*";
12
+ export declare const HL_ANCHOR_DECORATION_RE_RAW = "\\s*[>\\-*]*\\s*";
13
13
  /** Capture-group regex source for a decorated bare line-number anchor. */
14
- export declare const HL_ANCHOR_RE_RAW = "\\s*[>+\\-*]*\\s*(\\d+)";
14
+ export declare const HL_ANCHOR_RE_RAW = "\\s*[>\\-*]*\\s*(\\d+)";
15
15
  /** Bare positive line-number Lid (no decorations, no captures, no anchors). */
16
16
  export declare const HL_LINE_RE_RAW = "[1-9]\\d*";
17
17
  /** Capture-group form of {@link HL_LINE_RE_RAW}. */
@@ -45,9 +45,9 @@ export declare function resolveHashlineGrammarPlaceholders(grammar: string): str
45
45
  /**
46
46
  * op lines have an `ANCHOR<SIGIL>[INLINE_PAYLOAD]` shape, where SIGIL is one of
47
47
  * {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE},
48
- * or {@link HL_OP_DELETE}.
49
- * Multi-line payloads follow on subsequent lines as verbatim file content with no
50
- * per-line marker.
48
+ * or {@link HL_OP_DELETE}. Multi-line payloads follow on subsequent lines
49
+ * prefixed with {@link HL_PAYLOAD_PREFIX}; that prefix is stripped before the
50
+ * payload is written.
51
51
  *
52
52
  * These constants are the single source of truth for the edit parser, grammar,
53
53
  * renderer, and prompt.
@@ -56,6 +56,8 @@ export declare const HL_OP_INSERT_BEFORE = "\u2191";
56
56
  export declare const HL_OP_INSERT_AFTER = "\u2193";
57
57
  export declare const HL_OP_REPLACE = ":";
58
58
  export declare const HL_OP_DELETE = "!";
59
+ /** Prefix for payload continuation lines. The prefix itself is not written. */
60
+ export declare const HL_PAYLOAD_PREFIX = "+";
59
61
  /** All hashline edit op sigils, concatenated for fast membership tests. */
60
62
  export declare const HL_OP_CHARS = "\u2191\u2193:!";
61
63
  /** Hashline edit file section header marker. */
@@ -53,6 +53,9 @@ export type HashlineToken = (TokenBase & {
53
53
  }) | (TokenBase & {
54
54
  kind: "payload";
55
55
  text: string;
56
+ }) | (TokenBase & {
57
+ kind: "raw";
58
+ text: string;
56
59
  });
57
60
  /**
58
61
  * Stateful, line-oriented classifier for hashline diff text. Use the streaming
@@ -25,8 +25,8 @@ export interface ResolvedApproval {
25
25
  * 2. User per-tool override, if set and valid.
26
26
  * 3. Active mode tier comparison.
27
27
  *
28
- * Tool decisions with `override: true` force a prompt in every mode unless the
29
- * user explicitly denies the tool; deny remains the strongest policy.
28
+ * In yolo mode, override-based tool prompts are ignored; user `tools.approval`
29
+ * settings remain authoritative.
30
30
  */
31
31
  export declare function resolveApproval(tool: ApprovalSubject, args: unknown, mode: ApprovalMode, userConfig?: Record<string, unknown>): ResolvedApproval;
32
32
  /**
@@ -7,11 +7,11 @@ import type { ToolSession } from ".";
7
7
  import { type OutputMeta } from "./output-meta";
8
8
  export declare const BASH_DEFAULT_PREVIEW_LINES = 10;
9
9
  /**
10
- * Bash patterns that force an approval prompt even in yolo mode.
10
+ * Bash patterns flagged as safety critical for approval policy.
11
11
  *
12
- * Kept intentionally tight — the cost of a false positive is one extra prompt;
13
- * the cost of a false negative is data loss or a compromised host. New patterns
14
- * should target shapes that are virtually never legitimate in automation.
12
+ * Kept intentionally tight — the cost of a false negative is data loss or a compromised host,
13
+ * while false positives remain actionable through user policy control.
14
+ * New patterns should target shapes that are virtually never legitimate in automation.
15
15
  */
16
16
  export declare const CRITICAL_BASH_PATTERNS: readonly [RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp];
17
17
  declare const bashSchemaBase: z.ZodObject<{
@@ -42,6 +42,7 @@ export interface BashToolDetails {
42
42
  meta?: OutputMeta;
43
43
  timeoutSeconds?: number;
44
44
  requestedTimeoutSeconds?: number;
45
+ wallTimeMs?: number;
45
46
  terminalId?: string;
46
47
  async?: {
47
48
  state: "running" | "completed" | "failed";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.5.1",
4
+ "version": "15.5.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.5.1",
51
- "@oh-my-pi/pi-agent-core": "15.5.1",
52
- "@oh-my-pi/pi-ai": "15.5.1",
53
- "@oh-my-pi/pi-natives": "15.5.1",
54
- "@oh-my-pi/pi-tui": "15.5.1",
55
- "@oh-my-pi/pi-utils": "15.5.1",
50
+ "@oh-my-pi/omp-stats": "15.5.3",
51
+ "@oh-my-pi/pi-agent-core": "15.5.3",
52
+ "@oh-my-pi/pi-ai": "15.5.3",
53
+ "@oh-my-pi/pi-natives": "15.5.3",
54
+ "@oh-my-pi/pi-tui": "15.5.3",
55
+ "@oh-my-pi/pi-utils": "15.5.3",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -1805,7 +1805,7 @@ export const SETTINGS_SCHEMA = {
1805
1805
  // Default tool approval mode (interaction tab, but governs the tool wrapper).
1806
1806
  // "always-ask" — auto-approves read-tier tools only; prompts for write/exec.
1807
1807
  // "write" — auto-approves read and write-tier tools; prompts for exec.
1808
- // "yolo" — auto-approves every tier unless a tool declares `override: true`.
1808
+ // "yolo" — auto-approves every tier.
1809
1809
  "tools.approvalMode": {
1810
1810
  type: "enum",
1811
1811
  values: ["always-ask", "write", "yolo"] as const,
@@ -1814,7 +1814,7 @@ export const SETTINGS_SCHEMA = {
1814
1814
  tab: "interaction",
1815
1815
  label: "Tool Approval",
1816
1816
  description:
1817
- "Default approval behaviour for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves every tier unless a tool declares a safety override. `tools.approval.<tool>` overrides are honored in every mode.",
1817
+ "Default approval behaviour for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
1818
1818
  options: [
1819
1819
  {
1820
1820
  value: "always-ask",
@@ -1831,7 +1831,7 @@ export const SETTINGS_SCHEMA = {
1831
1831
  value: "yolo",
1832
1832
  label: "Yolo",
1833
1833
  description:
1834
- "Auto-approve read, write, and exec tools. Safety overrides declared by tools (for example critical bash patterns) still require confirmation.",
1834
+ "Auto-approve read, write, and exec tools. User policy can still require confirmation or block calls.",
1835
1835
  },
1836
1836
  ],
1837
1837
  },
@@ -2405,7 +2405,7 @@ export const SETTINGS_SCHEMA = {
2405
2405
  "task.simple": {
2406
2406
  type: "enum",
2407
2407
  values: TASK_SIMPLE_MODES,
2408
- default: "default",
2408
+ default: "schema-free",
2409
2409
  ui: {
2410
2410
  tab: "tasks",
2411
2411
  label: "Task Input Mode",
@@ -388,6 +388,9 @@ function buildHashlineNaturalOrderPreviews(
388
388
  case "abort":
389
389
  case "op-delete":
390
390
  continue;
391
+ case "blank":
392
+ case "raw":
393
+ continue;
391
394
  case "header":
392
395
  currentPath = token.path;
393
396
  if (currentPath) ensure(currentPath);
@@ -404,10 +407,6 @@ function buildHashlineNaturalOrderPreviews(
404
407
  if (!currentPath || token.inlineBody === undefined) continue;
405
408
  ensure(currentPath).push(`+${token.inlineBody}`);
406
409
  continue;
407
- case "blank":
408
- if (!currentPath) continue;
409
- ensure(currentPath).push("+");
410
- continue;
411
410
  case "payload":
412
411
  if (!currentPath) continue;
413
412
  ensure(currentPath).push(`+${token.text}`);
@@ -111,9 +111,8 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
111
111
  context?: AgentToolContext,
112
112
  ) {
113
113
  // 1. Check approval policy (before extension handlers).
114
- // CLI `--auto-approve` / `--yolo` forces yolo mode for the session, but
115
- // tool-level safety overrides still prompt. User `tools.approval.<tool>`
116
- // policies are honored in every mode.
114
+ // CLI `--auto-approve` / `--yolo` sets approval mode to yolo.
115
+ // User `tools.approval.<tool>` policies are still applied in all modes.
117
116
  const cliAutoApprove = context?.autoApprove === true;
118
117
  const settings: Settings | undefined = context?.settings;
119
118
  const configuredMode = (settings?.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
@@ -71,7 +71,7 @@ export class HashlineMismatchError extends Error {
71
71
  const pathText = details.path ? ` for ${details.path}` : "";
72
72
  return [
73
73
  `Edit rejected${pathText}: file changed between read and edit.`,
74
- `Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}; re-read and try again.`,
74
+ `Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If your previous edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash from that edit's response. Otherwise re-read the file before retrying.`,
75
75
  ];
76
76
  }
77
77
 
@@ -53,6 +53,57 @@ function validateHashlineLineBounds(edits: HashlineEdit[], fileLines: string[]):
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * Refuse a single-line replace whose target line is blank and whose payload is
58
+ * non-empty. The model is almost certainly miscounting: `A:CONTENT` overwrites
59
+ * the existing line, so applying it to a blank target deletes the blank cadence
60
+ * and inserts content in its place. To insert content at a blank line, use
61
+ * `A↑` (insert before) or `A↓` (insert after) instead.
62
+ *
63
+ * Only fires for the simple shape: exactly one `insert(before_anchor A)` + one
64
+ * `delete(A)` sharing the same source op line, no other inserts/deletes from
65
+ * that op.
66
+ */
67
+ function detectReplaceOnBlankTarget(edits: HashlineEdit[], fileLines: string[]): string | null {
68
+ type Pair = {
69
+ insert?: Extract<HashlineEdit, { kind: "insert" }>;
70
+ delete?: Extract<HashlineEdit, { kind: "delete" }>;
71
+ multi?: boolean;
72
+ };
73
+ const byOpLine = new Map<number, Pair>();
74
+ for (const edit of edits) {
75
+ const pair = byOpLine.get(edit.lineNum) ?? {};
76
+ if (pair.multi) continue;
77
+ if (edit.kind === "insert") {
78
+ if (pair.insert) pair.multi = true;
79
+ else pair.insert = edit;
80
+ } else {
81
+ if (pair.delete) pair.multi = true;
82
+ else pair.delete = edit;
83
+ }
84
+ byOpLine.set(edit.lineNum, pair);
85
+ }
86
+ for (const pair of byOpLine.values()) {
87
+ if (pair.multi || !pair.insert || !pair.delete) continue;
88
+ const insert = pair.insert;
89
+ const del = pair.delete;
90
+ if (insert.cursor.kind !== "before_anchor") continue;
91
+ if (insert.cursor.anchor.line !== del.anchor.line) continue;
92
+ if (insert.text.includes("\n")) continue;
93
+ if (insert.text.trim().length === 0) continue;
94
+ const targetLine = del.anchor.line;
95
+ const oldLine = fileLines[targetLine - 1];
96
+ if (oldLine === undefined || oldLine.trim().length !== 0) continue;
97
+ return (
98
+ `Edit rejected: replace at line ${targetLine} targets a blank line but the payload is non-empty. ` +
99
+ `'A:CONTENT' overwrites the line at A; to insert content next to a blank line, use 'A${"\u2191"}' (insert before) ` +
100
+ `or 'A${"\u2193"}' (insert after) instead. If you really meant to replace this blank with content, ` +
101
+ `widen the range to include surrounding non-blank lines so the intent is explicit.`
102
+ );
103
+ }
104
+ return null;
105
+ }
106
+
56
107
  function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
57
108
  if (lines.length === 0) return;
58
109
  const origins = lines.map((): HashlineLineOrigin => "insert");
@@ -160,10 +211,10 @@ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacem
160
211
  return 0;
161
212
  }
162
213
 
163
- // Single-line duplicate absorption is limited to structural closing delimiters.
164
- // General one-line context is too easy to delete incorrectly, but duplicated
165
- // `};` / `)` / `]` boundaries usually indicate a replacement range stopped one
166
- // line early and would otherwise produce a syntax error.
214
+ // Single-line replacement-boundary absorption is limited to structural closing
215
+ // delimiters. General one-line context is too easy to delete incorrectly, but
216
+ // duplicated `};` / `)` / `]` boundaries often mean a replacement range stopped
217
+ // one line early and would otherwise produce a syntax error.
167
218
  const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
168
219
 
169
220
  function isStructuralClosingBoundaryLine(line: string): boolean {
@@ -176,8 +227,6 @@ interface DelimiterBalance {
176
227
  brace: number;
177
228
  }
178
229
 
179
- const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
180
-
181
230
  /**
182
231
  * Naive bracket counter — does NOT skip string/template/comment contents. The
183
232
  * single-line structural absorb relies on this being safe-by-asymmetry: the
@@ -285,6 +334,7 @@ function countMatchingSingleNonStructuralPrefixDuplicate(
285
334
  ): number {
286
335
  if (replacement.length === 0 || startLine <= 1) return 0;
287
336
  const line = replacement[0];
337
+ if (line.trim().length === 0) return 0;
288
338
  if (isStructuralClosingBoundaryLine(line)) return 0;
289
339
  if (fileLines[startLine - 2] !== line) return 0;
290
340
  return 1;
@@ -297,6 +347,7 @@ function countMatchingSingleNonStructuralSuffixDuplicate(
297
347
  ): number {
298
348
  if (replacement.length === 0 || endLine >= fileLines.length) return 0;
299
349
  const line = replacement[replacement.length - 1];
350
+ if (line.trim().length === 0) return 0;
300
351
  if (isStructuralClosingBoundaryLine(line)) return 0;
301
352
  if (fileLines[endLine] !== line) return 0;
302
353
  return 1;
@@ -400,12 +451,11 @@ interface PureInsertAbsorbResult {
400
451
  }
401
452
 
402
453
  /**
403
- * Mirror of replacement-absorb's prefix/suffix block check, but for pure
404
- * inserts: drop payload lines that exactly duplicate the file lines
405
- * immediately above (leading) or immediately below (trailing) the insertion
406
- * point. Generic context echo absorption requires the caller's opt-in setting;
407
- * without it, only single structural closing delimiters use the
408
- * balance-validated structural rule below.
454
+ * For a pure-insert group, drop only multi-line context echoes that exactly
455
+ * duplicate the file lines adjacent to the insertion point. Single-line pure
456
+ * insert duplicates are ambiguous (`N↓}` may be an accidental anchor echo or an
457
+ * intentional inserted delimiter), so they are left literal even when generic
458
+ * duplicate absorption is enabled.
409
459
  */
410
460
  function tryAbsorbPureInsertGroup(
411
461
  group: HashlinePureInsertGroup,
@@ -435,33 +485,11 @@ function tryAbsorbPureInsertGroup(
435
485
  }
436
486
  }
437
487
  }
438
- if (
439
- absorbedLeading === 0 &&
440
- allowGenericBoundaryAbsorb &&
441
- group.cursor.kind === "after_anchor" &&
442
- group.payload.length > 0 &&
443
- aboveEndIdx >= 0 &&
444
- !isStructuralClosingBoundaryLine(group.payload[0]) &&
445
- group.payload[0] === fileLines[aboveEndIdx]
446
- ) {
447
- absorbedLeading = 1;
448
- }
449
- if (
450
- absorbedLeading === 0 &&
451
- group.payload.length > 0 &&
452
- aboveEndIdx >= 0 &&
453
- isStructuralClosingBoundaryLine(group.payload[0]) &&
454
- group.payload[0] === fileLines[aboveEndIdx] &&
455
- shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE)
456
- ) {
457
- absorbedLeading = 1;
458
- }
459
488
 
460
489
  // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
461
490
  // Don't double-count payload lines already absorbed as leading.
462
491
  let absorbedTrailing = 0;
463
- const remainingPayload = group.payload.slice(absorbedLeading);
464
- const remaining = remainingPayload.length;
492
+ const remaining = group.payload.length - absorbedLeading;
465
493
  if (allowGenericBoundaryAbsorb) {
466
494
  const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
467
495
  for (let count = maxTrail; count >= 2; count--) {
@@ -478,27 +506,6 @@ function tryAbsorbPureInsertGroup(
478
506
  }
479
507
  }
480
508
  }
481
- if (
482
- absorbedTrailing === 0 &&
483
- group.cursor.kind === "before_anchor" &&
484
- allowGenericBoundaryAbsorb &&
485
- remaining > 0 &&
486
- belowStartIdx < fileLines.length &&
487
- !isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
488
- remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx]
489
- ) {
490
- absorbedTrailing = 1;
491
- }
492
- if (
493
- absorbedTrailing === 0 &&
494
- remaining > 0 &&
495
- belowStartIdx < fileLines.length &&
496
- isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
497
- remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] &&
498
- shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE)
499
- ) {
500
- absorbedTrailing = 1;
501
- }
502
509
 
503
510
  if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
504
511
 
@@ -683,6 +690,9 @@ export function applyHashlineEdits(
683
690
 
684
691
  validateHashlineLineBounds(edits, fileLines);
685
692
 
693
+ const blankTargetError = detectReplaceOnBlankTarget(edits, fileLines);
694
+ if (blankTargetError !== null) throw new Error(blankTargetError);
695
+
686
696
  const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
687
697
 
688
698
  // Normalize after_anchor inserts to before_anchor of the next line, or EOF
@@ -20,3 +20,41 @@ export const ABORT_MARKER = "*** Abort";
20
20
  /** Warning text appended to the tool result when ABORT_MARKER terminates parsing. */
21
21
  export const ABORT_WARNING =
22
22
  "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
23
+
24
+ /**
25
+ * Warning text appended when two consecutive `A-B:` ops on the exact same
26
+ * range get coalesced (model painted a before/after pair). The second op
27
+ * wins; the first op's payload is silently discarded.
28
+ */
29
+ export const REPLACE_PAIR_COALESCED_WARNING =
30
+ "Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.";
31
+
32
+ /**
33
+ * Warning text appended when un-prefixed continuation lines are accepted as
34
+ * implicit payload (lenient legacy behavior). The model authored a multi-line
35
+ * replace without `+` prefixes; the parser accepted it because the lines did
36
+ * not classify as ops/headers/payloads, but the canonical syntax requires `+`
37
+ * on every continuation line after the op.
38
+ */
39
+ export const IMPLICIT_CONTINUATION_WARNING =
40
+ "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
41
+
42
+ /**
43
+ * Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
44
+ * op arrives while an outer `A-B:` replace is still pending and the inner
45
+ * anchor falls inside the outer range. The model used the read-output
46
+ * `LINE:TEXT` format as if it were a payload-continuation line; we strip the
47
+ * `LINE:` prefix and append the body to the pending payload, but warn so the
48
+ * canonical `+`-continuation form remains preferred.
49
+ */
50
+ export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
51
+ "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` — never reuse the read-output gutter format.";
52
+ /**
53
+ * Warning text appended when an op carries an inline payload (`LINE:TEXT`,
54
+ * `A-B:TEXT`, `LINE↑TEXT`, `LINE↓TEXT`). Canonical syntax is bare op +
55
+ * `+`-prefixed continuation rows; we accept the inline form leniently so the
56
+ * model's first-attempt edit still lands, but warn so the canonical form
57
+ * remains preferred.
58
+ */
59
+ export const INLINE_PAYLOAD_ACCEPTED_WARNING =
60
+ "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE↑CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
@@ -14,7 +14,7 @@ import { HashlineMismatchError } from "./anchors";
14
14
  import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
15
15
  import { buildCompactHashlineDiffPreview } from "./diff-preview";
16
16
  import { parseHashline } from "./executor";
17
- import { computeFileHash } from "./hash";
17
+ import { computeFileHash, formatHashlineHeader } from "./hash";
18
18
  import { splitHashlineInputs } from "./input";
19
19
  import { tryRecoverHashlineWithCache } from "./recovery";
20
20
  import type {
@@ -224,9 +224,10 @@ async function executeHashlineSection(
224
224
  // of the file: the model just received it back as the diff/preview. Cache
225
225
  // it so a follow-up edit anchored against this state can still recover
226
226
  // if the file is touched out-of-band before the next edit lands.
227
+ const newFileHash = computeFileHash(result.lines);
227
228
  getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), {
228
229
  fullText: result.lines,
229
- fileHash: computeFileHash(result.lines),
230
+ fileHash: newFileHash,
230
231
  });
231
232
 
232
233
  const diffResult = generateDiffString(originalNormalized, result.lines);
@@ -238,6 +239,7 @@ async function executeHashlineSection(
238
239
  const warnings = [...parseWarnings, ...(result.warnings ?? [])];
239
240
  const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
240
241
  const previewBlock = preview.preview ? `\n${preview.preview}` : "";
242
+ const newHashLine = `\n${formatHashlineHeader(sourcePath, newFileHash)}`;
241
243
  const headline = preview.preview
242
244
  ? `${sourcePath}:`
243
245
  : source.exists
@@ -245,7 +247,7 @@ async function executeHashlineSection(
245
247
  : `Created ${sourcePath}`;
246
248
 
247
249
  return {
248
- content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
250
+ content: [{ type: "text", text: `${headline}${newHashLine}${previewBlock}${warningsBlock}` }],
249
251
  details: {
250
252
  diff: diffResult.diff,
251
253
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,