diff --git a/docs/tools/edit.md b/docs/tools/edit.md
index 29a11e4c3..99e77f5ec 100644
--- a/docs/tools/edit.md
+++ b/docs/tools/edit.md
@@ -31,16 +31,16 @@
Patch language inside `input`:
- Section header: `¶PATH#HASH` for anchored edits, `¶PATH` for BOF/EOF-only inserts
-- Insert after: `LINE↓`
-- Insert before: `LINE↑`
-- Replace range: `A-B:`
-- Single-line replace sugar: `A:` means `A-A:`
+- Insert after: `LINE↓[payload]`
+- Insert before: `LINE↑[payload]`
+- Replace range: `A-B:[payload]`
+- Single-line replace sugar: `A:[payload]` means `A-A:[payload]`
- Delete range: `A-B!`
- Single-line delete sugar: `A!` means `A-A!`
-- `:` / `↑` / `↓` payload may be inline after the sigil and/or on subsequent payload lines. Bare `A↑` / `A↓` insert one blank line; bare `A:` / `A-B:` (no payload) replaces the line/range with a single blank line. Use `A!` / `A-B!` to delete entirely.
+- **Payload semantics:** the first payload line is whatever follows the sigil on the op line itself; additional payload lines follow on subsequent lines and append after the inline first line. An empty inline (just the sigil followed by a newline) means the first payload line is empty. So bare `A↑` / `A↓` insert one blank line, and bare `A:` / `A-B:` replace the line/range with one blank line. But `A↓\nfoo` inserts blank-then-`foo`, not just `foo` — for a single-line insert, put `foo` inline as `A↓foo`.
- `!` deletes and forbids payload.
-- Inline payload: content after `↓`/`↑`/`:` on the same line is the first payload line; subsequent lines append to it. Read lines like `84:content` are already valid single-line replacements.
-- Special anchors: `BOF`, `EOF`
+- Read lines like `84:content` are already valid single-line replacements.
+- Special anchors: `BOF`, `EOF` (both support inline payload, e.g. `BOF↓export const done = true;`).
- Anchor token: bare line number, for example `41`
- File binding: 4-hex hash in the section header, for example `¶src/a.ts#1a2b`
@@ -79,7 +79,7 @@ Warnings:
- ignores blank lines and optional `*** Begin Patch`
- stops at `*** End Patch`
- stops at `*** Abort` and emits `ABORT_WARNING`
- - turns `↓` / `↑` payload runs into one `insert` edit per payload line
+ - turns `↓` / `↑` payload runs (inline plus subsequent lines) into one `insert` edit per payload line
- turns `A-B:` with payload into inserts before `A`, then deletes for `A-B`
- turns `A-B!` into one `delete` edit per line in the range; payload is forbidden
6. `executeHashlineSingle()` computes the current file hash before applying anchored edits. If it differs from the section `#HASH`, recovery tries the read/search snapshot cache before any write.
@@ -103,51 +103,44 @@ Warnings:
- `patch` — structured JSON diff-hunk mode (`packages/coding-agent/src/edit/modes/patch.ts`).
- `apply_patch` — freeform Codex-style `*** Begin Patch` envelope, internally expanded into patch-mode entries (`packages/coding-agent/src/edit/modes/apply-patch.ts`).
-Hashline op examples:
+Hashline op examples (single-line payloads are inline; multi-line payloads continue on subsequent lines):
```text
¶src/a.ts#1a2b
-4↓
-const added = true;
+4↓const added = true;
```
```text
¶src/a.ts#1a2b
-4↑
-const addedBefore = true;
+4↑const addedBefore = true;
```
```text
¶src/a.ts#1a2b
-4-6:
-const replacement = true;
+4-6:const replacement = true;
```
```text
¶src/a.ts#1a2b
-4-5:
-const clean = (name || DEF).trim();
+4-5:const clean = (name || DEF).trim();
return clean.length === 0 ? DEF : clean.toUpperCase();
```
```text
¶src/a.ts#1a2b
-4:
-const clean = (name || DEF).trim();
+4:const clean = (name || DEF).trim();
```
BOF/EOF examples:
```text
¶src/a.ts
-BOF↓
-const HEADER = true;
+BOF↓const HEADER = true;
```
```text
¶src/a.ts
-EOF↓
-export const done = true;
+EOF↓export const done = true;
```
Delete / blank examples:
@@ -160,7 +153,6 @@ Delete / blank examples:
```text
¶src/a.ts#1a2b
4:
-
```
```text
@@ -172,9 +164,7 @@ Multi-file example:
```text
¶src/a.ts#1a2b
-4:
-const enabled = true;
-
+4:const enabled = true;
¶src/b.ts#3c4d
20!
```
@@ -238,6 +228,7 @@ const enabled = true;
- Multi-op patches are parsed against the original file snapshot. Do not renumber later anchors after earlier ops; `applyHashlineEdits()` buckets and applies them bottom-up.
- Failed hand-edits often come from sequentially shifting later anchors inside the same patch. Treat every op as using the line numbers from the original section header.
- `A-B:` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A-B`. `A-B!` is the direct delete form. Bare `A:` / `A-B:` (no payload) replaces with a single blank line; bare `↑` / `↓` insert a blank line.
+- Inline payload tip: trailing whitespace on the op line is trimmed. To preserve trailing spaces in the inserted/replacement content, put that content on the next line instead of inline.
- `computeFileHash()` normalizes CR characters and trailing whitespace before hashing. The section survives line-ending and trailing-space-only changes, but not substantive file edits.
- `splitHashlineInputs()` normalizes absolute `¶PATH#HASH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `¶` chars (e.g. `¶foo.ts`, `¶¶foo.ts`, `¶¶¶foo.ts`) are accepted; the canonical form is `¶PATH#HASH` for anchored edits.
- Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `¶PATH#HASH`-based, not Codex `*** Update File:` hunks.
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 7c8249277..431ae4ac5 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -4,6 +4,7 @@
### Breaking Changes
- The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode
+- Hashline payload semantics are now strictly inline-first: the first payload line is whatever follows the sigil on the op line itself, and subsequent lines append after it. A newline immediately after `↑`/`↓`/`:` is no longer a free separator — it produces a blank first payload line. Use `LINE↓content` for a one-line insert, `LINE↓firstline\nsecondline` for two lines; bare `LINE↓` / `LINE↑` / `LINE:` (no inline payload) still insert/replace with one blank line as before.
### Added
diff --git a/packages/coding-agent/src/hashline/executor.ts b/packages/coding-agent/src/hashline/executor.ts
index 8f7b85afa..4c6849762 100644
--- a/packages/coding-agent/src/hashline/executor.ts
+++ b/packages/coding-agent/src/hashline/executor.ts
@@ -105,7 +105,7 @@ export class HashlineExecutor {
this.#flushPending(false);
this.#pending = {
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
- payload: token.inlineBody === undefined ? [] : [token.inlineBody],
+ payload: [token.inlineBody ?? ""],
pendingBlanks: 0,
};
return;
@@ -114,7 +114,7 @@ export class HashlineExecutor {
validateRangeOrder(token.range, token.lineNum);
this.#pending = {
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
- payload: token.inlineBody === undefined ? [] : [token.inlineBody],
+ payload: [token.inlineBody ?? ""],
pendingBlanks: 0,
};
return;
@@ -186,7 +186,7 @@ export class HashlineExecutor {
if (includeTrailingBlanks) this.#flushPendingBlanks();
const { op, payload } = pending;
- const linesToInsert = payload.length === 0 ? [""] : payload;
+ const linesToInsert = payload;
if (op.kind === "insert") {
for (const text of linesToInsert) {
diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md
index ef9efc57a..b0887c968 100644
--- a/packages/coding-agent/src/prompts/tools/hashline.md
+++ b/packages/coding-agent/src/prompts/tools/hashline.md
@@ -1,61 +1,40 @@
Your patch language is a compact, line-anchored edit format.
-A patch contains one or more file sections. The first non-blank line of an anchored edit section MUST be `¶PATH#HASH`, copied from the latest `read`/`search` output for that file. `HASH` is a 4-hex file hash.
-Operations reference lines by bare line number, e.g. `5`, `123`.
+A patch contains one or more file sections. Each anchored section starts with `¶PATH#HASH`, copied verbatim from the latest `read`/`search` output. `HASH` is a 4-hex file hash; `¶PATH` without `#HASH` is allowed only for new-file / `BOF` / `EOF` boundary inserts.
-`¶PATH` without `#HASH` is allowed ONLY for new-file / `BOF` / `EOF` boundary inserts. Anchored line ops without a header hash are rejected.
-
-Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions.
+Operations reference lines by bare line number (`5`, `123`). Payload text is verbatim — NEVER escape unicode. The tool has NO awareness of language, indentation, brackets, fences, or table widths. Emit valid syntax in replacements/insertions.
-¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH
-¶PATH unbound header: only BOF/EOF boundary inserts
-Each op line is ONE of:
-LINE↑ insert ABOVE the anchored line (or BOF); payload may follow inline after `↑` and/or on subsequent lines
-LINE↓ insert BELOW the anchored line (or EOF); payload may follow inline after `↓` and/or on subsequent lines
-A-B: replace the inclusive range A..B with payload
-A: shorthand for A-A:
-A-B! delete the inclusive range A..B; payload forbidden
-A! shorthand for A-A!
+¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH
+¶PATH unbound header: only BOF/EOF boundary inserts
+LINE↑PAYLOAD insert ABOVE the anchored line (or BOF)
+LINE↓PAYLOAD insert BELOW the anchored line (or EOF)
+A-B:PAYLOAD replace the inclusive range A..B with PAYLOAD
+A:PAYLOAD shorthand for A-A:PAYLOAD
+A-B! delete the inclusive range A..B (payload forbidden)
+A! shorthand for A-A!
+
+- The first payload line is whatever follows the sigil on the op line. Additional payload lines follow on the next lines and append after the first.
+- An empty inline IS an empty first line. So bare `A↓` / `A↑` insert one blank line; bare `A:` / `A-B:` replace with one blank line. `A↓\nfoo` inserts blank-then-`foo`, NOT just `foo`.
+- Payload ends at the next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are dropped; blank lines between content lines are preserved.
+
+
- The sigil tells where content lands: `↑` above, `↓` below, `:` replaces, `!` deletes.
-- Payload text is verbatim — NEVER escape unicode.
-- Op line shape: `ANCHOR[INLINE_PAYLOAD]`.
-- Payload ends at next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are treated as separators (dropped); blank lines between two content payload lines, or trailing at EOF, are preserved.
-- `:` / `↑` / `↓` payload may be inline after the sigil and/or on subsequent lines.
-- A bare `A↑` / `A↓` (no payload) inserts one blank line. A bare `A:` / `A-B:` (no payload, no inline body) replaces the line/range with a single blank line.
-- `!` delete ops NEVER include payload.
-- Blank a line with bare `A:`, or remove it entirely with `A!`. Insert a blank line with bare `A↑` / `A↓`.
-- **Payload is only what's NEW relative to your range:**
- - `:` replaces inside; NEVER include lines outside.
- - `↑`/`↓` adds at anchor; NEVER repeat line A or neighbors.
- - Payload matching nearby content duplicates — drop it or widen.
-- **Pick a self-contained unit first.** Touching multiline construct? Widen to it.
-- Then smallest op: add with `↑`/`↓`; replace with `:`; delete with `!`.
+- **Payload is only what's NEW relative to your range.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat the anchor line or neighbors — that duplicates them.
+- **Pick a self-contained unit.** Touching a multiline construct (return, array, brace block, JSX element)? Widen the range to span it. Don't bisect.
+- Smallest op wins: add with `↑`/`↓`; replace with `:`; delete with `!`.
+- Anchors reference the file as last read. ONE patch, ONE coordinate space — later ops still use original line numbers.
-
-When braces bound your edit, you SHOULD prefer these shapes:
-- **Whole block**: range spans `{` through matching `}`.
-- **Signature only**: one-line `:` on opener; body untouched.
-- **Insert inside**: anchor on `{` or last interior line; NEVER repeat braces.
-- **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier.
-
-
- **NEVER replay past your range.** Stop before B+1; extend B if it must go.
-- **NEVER duplicate chunks inside one payload.** Caught re-emitting? Rewrite.
-- **Anchor only inside visible content.** B+1 truncated? Re-`read` first.
-- **Use the section hash from latest output.** Missing/stale? Re-`read`.
-- **You SHOULD prefer the narrowest self-contained edit.** Narrow range beats wide range.
-- **Anchors reference the file as last read.** NEVER shift for prior ops.
-- **One patch, one coordinate space.** Later ops still use original line numbers.
-- **Read lines already look like replace ops.** `84:content` already means “make line 84 equal to content”. Do not echo a second context line before it.
-- **One `↓`/`↑` op per block, NOT per line.** N lines = ONE op, N payloads.
+- **NEVER duplicate chunks inside one payload.**
+- **Read lines look like replace ops.** `84:content` already means "make line 84 equal to content" — don't echo a context line before it.
- **NEVER fabricate file hashes.** Missing? Re-`read`.
-- **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error. If you misfired an earlier edit and reach for `A!` to clean up, re-read first — you'll get a warning if the deleted line was a structural boundary, but the warning only fires after the fact.
+- **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error.
@@ -70,47 +49,37 @@ When braces bound your edit, you SHOULD prefer these shapes:
-# Replace one line (payload must re-emit original indentation)
+# Replace one line (inline payload preserves original indentation)
¶mod.ts#1a2b
-{{hrefr 1}}:
-const TITLE = "Mrs";
+{{hrefr 1}}:const TITLE = "Mrs";
-# Replace a full multiline statement (widen to self-contained boundary)
+# Replace a multiline statement — first line inline, rest below
¶mod.ts#1a2b
-{{hrefr 3}}-{{hrefr 6}}:
- return [
+{{hrefr 3}}-{{hrefr 6}}: return [
"Mrs",
name?.trim() || "guest",
].join(" ");
-# Delete one line
+# Insert ABOVE / BELOW a line
+¶mod.ts#1a2b
+{{hrefr 4}}↓ "Dr",
+{{hrefr 5}}↑ "Dr",
+
+# Delete one line / blank a line / insert a blank line
¶mod.ts#1a2b
{{hrefr 5}}!
+{{hrefr 6}}:
+{{hrefr 7}}↑
-# Blank a line
-¶mod.ts#1a2b
-{{hrefr 5}}:
-# Insert ABOVE/BELOW a line
-¶mod.ts#1a2b
-{{hrefr 4}}↓
- "Dr",
-{{hrefr 5}}↑
- "Dr",
-
-# Append to existing file; hash optional because EOF is a boundary insert
-¶mod.ts
-EOF↓
-export const done = true;
-
-# Create a file
+# Create a file / append to one (hash optional for boundary-only inserts)
¶new.ts
-BOF↓
-export const done = true;
+BOF↓export const done = true;
+¶mod.ts
+EOF↓export const done = true;
# Multi-file patch
¶src/a.ts#1a2b
-12:
-const enabled = true;
+12:const enabled = true;
¶src/b.ts#3c4d
20!
@@ -118,36 +87,23 @@ const enabled = true;
# WRONG — replaces 2 lines just to add one.
¶mod.ts#1a2b
-{{hrefr 1}}-{{hrefr 2}}:
-const TITLE = "Mr";
+{{hrefr 1}}-{{hrefr 2}}:const TITLE = "Mr";
const DEBUG = false;
export function greet(name) {
-# RIGHT — same effect, one-line insert
-¶mod.ts#1a2b
-{{hrefr 1}}↓
-const DEBUG = false;
-# WRONG — replace from the middle of a larger statement (error-prone)
+# RIGHT — one-line insert
¶mod.ts#1a2b
-{{hrefr 4}}-{{hrefr 5}}:
- "Dr",
+{{hrefr 1}}↓const DEBUG = false;
+
+# WRONG — bisects a multiline statement
+¶mod.ts#1a2b
+{{hrefr 4}}-{{hrefr 5}}: "Dr",
name?.trim() || "guest",
+
# RIGHT — widen to the full statement
¶mod.ts#1a2b
-{{hrefr 3}}-{{hrefr 6}}:
- return [
+{{hrefr 3}}-{{hrefr 6}}: return [
"Dr",
name?.trim() || "guest",
].join(" ");
-
-
-- Copy the `¶PATH#HASH` header verbatim for anchored edits.
-- Copy only line numbers into ops; NEVER include `:TEXT` body unless you are intentionally using `LINE:TEXT` as replace syntax.
-- NEVER write unified diff syntax. Ops put `↑`/`↓`/`:`/`!` AFTER the anchor.
-- `:` replaces; bare `A:` blanks the line. `↑` / `↓` insert; bare `A↑` / `A↓` insert one blank line. Use `A!` to delete entirely.
-- `!` deletes and forbids payload.
-- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `:`.
- - Before `A-B:` or `A-B!`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct.
-- NEVER use this tool to reformat code. Run the project's formatter instead.
-