<h1 align="center">dsh-hashline-edittool</h1>

<p align="center">
  <img src="docs/images/cards.png" alt="hashline cards: read / edit / grep / LSP / settings" width="760">
</p>

<p align="center">
  <strong>Line-anchored editing for DeepSeek Harness<br>
  Every line gets a variable-length content anchor — no line numbers, no echoing old code, fewer tokens, more context for real work.</strong>
</p>

<p align="center">
  <strong>English</strong> ·
  <a href="README.zh.md">简体中文</a>
</p>

<p align="center">
  <a href="#quick-start">Quick Start</a> •
  <a href="#the-anchor-contract">The Anchor Contract</a> •
  <a href="#tools">Tools</a> •
  <a href="#settings">Settings</a> •
  <a href="#error-codes">Error Codes</a> •
  <a href="#architecture">Architecture</a> •
  <a href="#acknowledgments">Acknowledgments</a>
</p>

<p align="center">
  <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
  <img src="https://img.shields.io/badge/DeepSeek_Harness-Plugin-blueviolet.svg" alt="DeepSeek Harness Plugin">
  <img src="https://img.shields.io/npm/v/dsh-hashline-edittool" alt="npm version">
  <img src="https://img.shields.io/github/stars/hyperion2144/dsh-hashline-edittool?style=social" alt="GitHub Stars">
  <a href="https://dsh-plugin.org/plugins/hyperion2144/dsh-hashline-edittool"><img src="https://dsh-plugin.org/badges/listed.svg" alt="Listed on dsh-plugin.org"></a>
</p>

---

## What it is

A [DeepSeek Harness](https://github.com/deepseek-ai) plugin that replaces the built-in
`read` / `edit` / `grep` tools with **hash-anchored** versions and adds `delete`, `rename`,
`undo_last_edit`, `ast_grep`, `ast_edit`, `apply_patch`, and an `lsp` tool on top:

- **Every line carries a content anchor** — a variable-length Base62 marker (2 characters
  covers the first 3,844 lines; the encoding grows only as the file demands). The model
  edits by marker, so it never echoes the code it is replacing.
- **Edits are verified against what the model actually saw.** Each referenced anchor is
  checked line by line: it must still be live, its line must still hold the content it
  was minted for, and it must be in *this session's* served set. A line that changed
  under the agent is refused — and the refusal echoes the failing lines **with real,
  immediately usable anchors** (reject-and-serve). A changed file checksum is a *remap*
  signal, not a refusal, so another session touching another line never invalidates yours.
- **Anchors are shared across sessions in one workspace.** Read a file in one session and
  edit it from another without re-reading: anchors are a per-workspace identity, while
  "may I write this line" is answered per session. If the other session *replaced* the
  line you are holding, you get a refusal and the line's current anchor — not a silent
  write somewhere else.
- **One atomic batch per file.** Every anchor in one `edit` resolves against the original
  snapshot, so all items use ORIGINAL anchors. Within one file the edits are all-or-nothing:
  a failure rejects that file's batch (`[E_BATCH_ABORT]`) and writes nothing for it. Files are
  independent — a multi-file call reports partial success per file, each failure with its own code.
- **Everything is a card.** The bundled client plugin renders read / diff / grep / undo /
  write / structural / LSP cards in the dsh web UI from structured `presentationMeta` —
  the model text and the UI never have to agree by string parsing.
  Under the PTC preset a `run_code` sub-call carries that same meta, so the same card renders (#353).

Ships as one npm package (`dsh-hashline-edittool`): host plugin + web card plugin + prompt
sections, mounted by a single bundle patch.

## Highlights

**Self-rendering cards.** The bundled client plugin ships its own React components —
`HashlineReadRow`, `HashlineEditRow`, `HashlineGrepRow` (file tabs + match highlighting),
`HashlineUndoRow`, `HashlineWriteRow`, `HashlineAstGrepRow`, `HashlineAstEditRow`,
`HashlineLspRow` — registered straight into the dsh web UI's slots. Every card renders
from the tool's structured `presentationMeta`, with anchor gutters, diff rows, and
highlight spans drawn natively. No generic tool-output cards, no string parsing, no
upstream web changes.

**Card chrome follows the reader.** The five card kinds (read / grep / diff / LSP /
error) wrap by default, and a button in the card title switches a card back to
horizontal scrolling (#373). The card text follows the DSH interface language (zh/en;
any other language falls back to English): the wording lives in the plugin's own
`hashline.card` vocabulary, and the host sends stable keys over the wire instead
of prose (#372, #375).

**Dynamic-length anchors.** Anchors are not fixed-width hashes. Allocation is
shortest-first: 2 characters cover the first 3,844 lines, and a new layer grows only when
the file demands it (up to 62⁸ lines — practically unbounded). All-digit encodings are
skipped so a marker can never be confused with a line number, collisions are probed and
resolved at allocation time, and surviving lines keep their anchors across edits within
the session.

**AST + LSP, two semantic backends.** `ast_grep` / `ast_edit` answer "where does the
syntax match" through a sandboxed tree-sitter worker, with a curated grammar catalog
(SHA-256-pinned downloads, install/uninstall routes) and folded editable outlines for
long files. The `lsp` tool answers "what does this symbol mean" through a real language
server per language — started on demand, shared with dsh's own `lsp` service — and falls
back to a heuristic backend when none can be had. Both serve their rows, so structural and
semantic results are directly editable. A server nobody is using is reclaimed after
`lsp.idle_timeout_ms` (five minutes by default) and warmed again on the next use — a
TypeScript server holding a large repository's index is not something to keep
resident for the whole session. A server that fails to start retries on the next use once its backoff window passes (5 s doubling, capped at 1 min; the counter resets on success), `lsp { operation: "warm", language }` forces an immediate restart past the backoff, and `status` reports every started language's server state without starting anything (#318).

**Diagnostics on write.** After an edit/write lands, the plugin baselines the language server with the pre-write text, pulls diagnostics, and delivers them per written file — an inline, severity-tinted capsule under the card within a short window, with bounded async delivery at the model's next natural step, riding both the JSON envelope and the text channel. One switch (`lsp.auto_diagnostics`) turns it off.

**Formatting through the language server.** The `format` tool asks the server's formatter to do the work — with indentation read from the project's `.editorconfig` when present (`format.editorconfig`, on by default) and otherwise inferred from the file's own indent **steps** (a deep 4-space file stays 4-space; #336). `format.auto` is a three-way switch (#315): **off** (default) never formats behind the tools' backs, **full** formats the whole file after every content-changing `write`/`edit`/`ast_edit`, and **range** formats only the lines the call touched — falling back to whole-file only when the server lacks range formatting AND `format.fallback_full_file` is on (otherwise the pass is skipped silently, and the `format` tool with an explicit range refuses with `E_FORMAT_UNSUPPORTED` instead of guessing). Ready servers only (never started for this), a 2 s budget per file, one warning + a skipped section when a request fails. The card shows the formatter's extra changes as their own section under the diff — header first (`[auto-format: server · mode · requested → applied · n lines changed · k in main diff · r restored · u unserved]`) — and never repeats what the diff already shows: rows the main diff carries are counted in the header instead of drawn again (#312), rows the formatter reverted back to their pre-edit text are located, not quoted — each contiguous run becomes a `@@ ~start,count @@ scope` locator plus a one-line restored summary (#315) — and rows this session never served carry **no anchor**: the formatter's rewrite is shown exactly once, but only rows you had served can be re-edited by anchor.

**Write preview.** `write`'s post-write echo is the user's call (`write.preview`, map #354). **diff** (default) renders an overwrite as the same unified diff every edit tool uses — `--- path` / `+++ path`, the `@@ … @@ scope()` header, `context_lines` of context, and `+`/`-` rows whose `+` and context anchors are editable immediately; a create has no before side, so it stays read-style. **full** keeps the whole-file auto-read preview. **confirmation** returns one line (`Wrote <path> (update).`) and serves nothing: no file window, no resume token, no anchors — the card still draws the diff, but its gutter carries line numbers only. JSON output follows the same choice: `diff` answers with the marker-keyed diff dictionary `edit` uses, `confirmation` with `{path, operation}`.

**A settings panel rendered by the plugin itself.** The bundled `HashlineSettingsCard` is
a full settings UI in the web: separator, output format, context lines, line numbers
(the model-facing marker shape — the user's call, not the agent's), require_line_content,
the AST master switch plus per-language toggles, named LSP servers, auto-diagnostics,
the language server's idle-reclaim window.
Edit, commit, done — no YAML editing required.

**Hot switching, everywhere.** A committed settings change takes effect on the **next
tool call** — output format, separator, context lines, AST/LSP toggles (verified live:
flipping `output_format` mid-session immediately changes what the model receives). And the
switch with the biggest blast radius is handled too: flipping `require_line_content`
disposes and re-registers the `edit` tool's schema, so the model's very next step sees the
new `{ anchor, line }` parameter set — no restart anywhere.

## Quick Start

```sh
npx @deepseek-ai/dsh plugin --profile web add github:hyperion2144/dsh-hashline-edittool   # from github
npx @deepseek-ai/dsh plugin --profile web add dsh-hashline-edittool                       # from npm
npx @deepseek-ai/dsh plugin --profile web add /path/to/dsh-hashline-edittool              # local checkout
```

The profile's next session runs with the hashline tools installed. Verify the layer is active:

```sh
dsh --profile <name> --dump-config   # shows a "# == dsh-hashline-edittool" layer
```

| Requirement | |
| --- | --- |
| Node | `^22.19.0 \|\| >=24.0.0` (dsh's requirement; the store uses `node:sqlite`) |
| Profile | a dsh profile (`dsh plugin` initializes one on first use) |
| Backends | sandboxed / remote filesystems supported (writes go through `ctx.fs`) |

## The Anchor Contract

### Markers

- An anchor is a variable-length Base62 marker, **unique per line** (identical content
  lines get *distinct* anchors — the anchor is a row identity, not a content hash you can
  guess). Digits-only encodings are skipped, so a marker is never all digits.
- A marker is written `<anchor>` or `<anchor>:<line>`; `line` is a **positional hint only**
  — the anchor is authoritative, and a disagreeing hint is a warning
  (`[E_LINE_HINT]`), not an error. The legacy `<line>:<anchor>` order is still accepted.
- `read` output opens with an `ANCHOR:FILELINE` header separating the marker column from
  verbatim content, using the configured separator (`|` in the examples below):

```text
ANCHOR:FILELINE
G8:1|// UI demo file
ur:2|export const APP = "hashline";
D0:4|export function greet(name: string): string {
```

### Edit diffs (unified diff)

Every edit answers with a **unified diff** — file header, segment headers, then the
rows — not a floating list of changed lines:

```text
Diff rows: <+|-><anchor>:<content> — a unified diff: `@@ -old,count +new,count @@ scope()` gives the line range and the enclosing function(s); `-` rows are the pre-edit lines (their `~` anchors are stale), `+` rows and unchanged rows are the post-edit code.
--- src/render/line-diff.ts
+++ src/render/line-diff.ts
@@ -194,7 +194,7 @@ myersRange()
  B1:		trace.push(v.slice());
  Yc:		for (let k = -d; k <= d; k += 2) {
  jt:			let x: number;
-~Tl:			if (k === -d || (k !== d && v[mid + k - 1]! < v[mid + k + 1]!)) x = v[mid + k + 1]!;
+ bj:			if (k === -d || (k !== d && v[mid + k - 1]! <= v[mid + k + 1]!)) x = v[mid + k + 1]!;
  5X:			else x = v[mid + k - 1]! + 1;
  Tg:			let y = x - k;
  nm:			while (x < n && y < m && a[aLo + x] === b[bLo + y]) {
```

- `--- path` / `+++ path` — the file. A multi-file edit prints one group per file.
- `@@ -oldStart,oldCount +newStart,newCount @@ scope()` — one **segment**: the line range before and after the edit (context lines counted, git convention) plus the enclosing function or class — every scope the segment's changes fall into, joined by ` / ` when one segment touches more than one. The language is chosen by file extension or name (TS/JS, Vue/Svelte, Python, Ruby, Go, Rust, Zig, Java, Kotlin, Scala, C#, Swift, the C family, Dart, Groovy, Solidity, PHP, Perl, Shell, PowerShell, Lua, Elixir, Julia — the same way git picks its pattern table, and table-driven so a new language is one row). The name is information only — a file whose scope cannot be detected simply has no name.
- `-` rows are the **pre-edit** lines. Their anchor is stale and carries a `~`, so a model that copies it gets a clean `[E_BAD_REF]` instead of a rescued-then-stale anchor.
- `+` and unchanged rows are the **post-edit** code, and their anchors are fresh and editable.
- How much of the file a segment covers follows `hashline.context_lines`: raise it to see more of the function around the change, lower it to split a distant change into several segments.

### Served-state verification (reject-and-serve)

A row becomes **served** when a tool result shows it to the model (`read`, `grep`, edit
diffs, structural results, LSP rows). Anchors are minted at exactly that moment, through
**one** entry point, and the mint and the served record commit together — an anchor the
model was shown is always an anchor some session has "seen", which is what the
verification below relies on.

**Allocation happens after the response is truncated.** A line cut by the response budget
is a line the model never saw, so it is neither anchored nor served; it gets its anchor
when a later call actually shows it. "I hold a marker" therefore always means "I have
seen that line".

`edit` verifies each referenced anchor **line by line**, against three conditions — all
of which must hold:

1. the anchor is **live** — it is in the file's anchor state and the line it is bound to
   still carries the content the anchor was minted for;
2. the anchor is in **this session's** served set;
3. the line number the caller reported is **information only** — a mismatch is reported
   as drift, never a refusal.

**A changed file checksum is not itself a rejection.** It is the signal that the file was
changed by something else, and it triggers a **remap**: unchanged lines keep their
anchors at their new line numbers, and only lines whose content actually changed are
released and re-minted. Refusing on the checksum would make a line uneditable the moment
someone else touched any other part of the file — which is exactly what cross-session
anchors exist to avoid.

**Releasing an anchor clears three things together**: the anchor stops being live, it
leaves the *releasing* session's served set, and it enters that call's release pool so the
same call cannot hand it to a different line. Anything less leaves the silent
wrong-line window: an anchor that resolves, verifies and points somewhere new.

Failure is **all-or-nothing** and the code is unchanged:

- anchor unknown → `[E_STALE]`; a range row never served in this session → `[E_RANGE_UNVERIFIED]`;
- a changed file checksum → `[E_RANGE_STALE]`, a remap signal rather than a refusal. The host's
  own `FS_STALE_VERSION` (the write was refused by the file-version guard) maps onto the same code
  because the remedy is the same — re-read for fresh anchors — and only the first case is a remap signal;
- every rejection **echoes the failing lines with a little context, as served rows with
  real anchors**, so the fix is: take the marker from the echo and resubmit. Served rows
  are also emitted as `fs/observed`, so they can be written with immediately.

There is no `Shift:` block — after an edit, take anchors from the diff rows the response
just gave you, or re-read.

### Batch semantics

- `edits[]` apply **in order against one snapshot**; overlapping ranges are
  `[E_BATCH_CONFLICT]`; any failure in that file's batch is `[E_BATCH_ABORT]` and that file is
  left untouched.
- With per-item `path` (or every item carrying `path`), items are grouped per file and each
  file is **all-or-nothing independently**; results aggregate as `success[]` / `fail[]`
  (multi-file form). `item.path === topLevelPath` is auto-folded to absent.
- **Partial failure is visible**: when at least one file succeeds and another fails, the call is
  *not* a failure — every failed file is reported with its own code and recovery hint, and the web
  card draws a `N of M files failed` banner plus one tab per failed file (marked in the tab strip).
  The aggregate `[E_BATCH_ABORT]` message, and its "nothing was written", belongs to calls where
  **every** file failed.
- No cap on the number of edits per call — each edit addresses its own line, so a batch is only as long as the change.

### `op` semantics

| op | anchor field | behavior |
| --- | --- | --- |
| `replace` | `anchor_start` (+ optional `anchor_end`) | swap the range for `lines` (non-empty; **one element = one line** — only an element's own newline adds a line; `[""]` clears a line to empty, distinct from `del`). `anchor_end` omitted = single-line replace; **required when the range spans multiple lines**. |
| `ins` | `anchor_after` | insert `lines` **below** that line — the anchor line is kept, `lines` holds only what is new. `anchor_start`/`anchor_end` are refused. May anchor on another hunk's range **end** line, never its start/interior. A pasted row (`<anchor>:<content>`) is salvaged to its anchor with an `[E_BAD_REF]` warning — the anchor line is never replaced. |
| `del` | `anchor_start` (+ optional `anchor_end`) | delete the range (or the single `anchor_start` line); `lines` is ignored. |
| `sed` | `anchor_start` (+ optional `anchor_end`) | rewrite the range **line by line** with `pattern` + `replacement` + optional `flags` (`gims`), no `lines`, no newline in `replacement`; sed's `\1`/`&` and JS's `$1`/`$&` both accepted. |

Mixing anchor fields with the wrong op is `[E_BAD_SHAPE]`.

### `require_line_content` (optional hardening)

When `hashline.require_line_content` is on, every anchor becomes a
`{ anchor, line }` pair — `line` is your declaration of that row's **current full text**.
Declarations are verified after the stale-anchor check; a mismatch rejects that file's batch with
`[E_CONTENT_MISMATCH]` and echoes where your declared content actually lives.

### `grep_respect_gitignore` (default on)

`grep` builds its candidate list with ripgrep, so `.gitignore` / `.ignore` rules decide what
is searched at all: build output and vendored copies never reach the read stage (nor its
64 MiB budget). Only the **searched tree's own** ignore files apply — naming an ignored
directory on purpose still searches it. Turn `hashline.grep_respect_gitignore` off to
search what the plugin's own walk sees (the whole tree minus hidden entries and
`node_modules`), which is also the behavior whenever ripgrep cannot be resolved
or an invocation fails — `grep` never fails because ripgrep did.

## Tools

| Tool | What it does |
| --- | --- |
| `read` | File as served rows: `ANCHOR:FILELINE` header + `<anchor>:<line>` markers, or the bare `<anchor>` — that shape is the user's `line_numbers` setting (off by default), no longer a tool parameter, and a call still carrying it is `[E_BAD_SHAPE]`. `offset` takes a 1-based line **or an anchor** (inclusive start; a bare digit string is that number serialized as text and parses as one); `limit` takes a row count **or an anchor** (inclusive end) — with numbering off, the anchor is the only cursor you can see. Every text-mode window ends with one sentence: `[Lines X-Y of N. Omitted K lines. Use read {resume: "TOKEN"} to continue.]` when the body was truncated, otherwise `[Lines X-Y of N. Use offset="ANCHOR" to continue.]` (`offset=N` for a plain line cursor), and `[Lines X-Y of N. End of file.]` at the end of the file. Oversize lines (>200 KB) become a marker + `sed` hint — anchors need full lines. A file whose bytes are not valid UTF-8 still reads: decoded lossily (`U+FFFD`), rendered as unserved `[line N] content` rows, not editable — but showing is observing: a lossy read still tells the host the file was seen, so `write` may overwrite it (`edit` still refuses). |
| `edit` | One or more range edits via `{ path?, edits: [{ op, … }, …] }` — the full contract is [above](#the-anchor-contract). Replaces the legacy `batch_edit`. A lossy file (not valid UTF-8) is refused (`[E_LOSSY_BYTES]`); a lossless one (NUL bytes in valid UTF-8) edits through byte-exactly. |
| `write` | Fully shadowed: creates/overwrites a file and returns the write **plus a post-write echo**, whose shape is the user's `write.preview` (map #354). **diff** (default) renders an overwrite as the same unified diff `edit` uses — the next edit needs no separate read — while a create has no before side and stays read-style; **full** keeps the whole-file auto-read preview with fresh anchors; **confirmation** returns one line (`Wrote <path> (create\|update).`) and serves nothing, so read before editing what it wrote. |
| `grep` | Ripgrep regular-expression search (or `regex: false` for literal) across a path tree, one section per file under the same header, full lines only. **One `rg` invocation walks the path** with ripgrep's own ignore rules (#414): a build directory a parent `.gitignore` excludes is never walked, and ripgrep produces the rows (`match` + `context`). The anchors, the section budget and the spill are the plugin's own; a row is anchorable only when its text is the real line — a preview cut at 2000 bytes or a non-UTF-8 line is shown unserved. The search stops at a 20 MB raw-output ceiling (a loud failure, never a second engine), and a named path under an ignored ancestor gets ONE wide retry when the first search found nothing. Every section ends with that file's own count line, and `truncatedBy` names the cut. |
| `undo_last_edit` | `{ path }` reverts the file's last hashline edit — only while the file still matches the stored post-edit content; survives restarts. |
| `ast_grep` | Structural search by syntax shape (`pat` with `$NAME` / `$$$ARGS` / `$_` metavariables). Refuses instead of guessing when a pattern does not parse as one node. Long files come back as a folded, editable outline. |
| `ast_edit` | Structural rewrite: finds places by shape and hands the change to the **same engine** `edit` uses — served-state check, undo entry, diff and syntax gate all apply. |
| `lsp` | Symbol-aware work through a real language server when one can be had (started per language, shared with dsh's `lsp` service). `goToDefinition` / `findReferences` / `goToImplementation` / `hover` answer at a cursor (`line` with `symbol`, or `character`), and every hit is served as an editable row — a reference can be handed straight to `edit`. A method the server never advertised is refused as `[E_LSP_UNSUPPORTED_OPERATION]` instead of being asked. `symbols` / `code_actions` / `diagnostics` / `request` cover the rest; `status` lists every started language's server state without starting anything, and `warm` starts (or restarts) one on demand. A navigation answer is capped at 100 locations and says how many it cut. |
| `format` | Formats a file with its language server's formatter — the whole file, or `range: {start, end}` where **both ends are this session's served anchors**. The file must already be observed (`[E_NOT_OBSERVED]` otherwise); `noop` when the formatter changes nothing; enters undo like `edit`. A cold start is allowed here (15 s) because you asked for it by name. |
| `delete` | Deletes regular files and reclaims their hashline state: `paths` (1–32, duplicates collapse) are removed one by one, then the path's `anchor_meta` / `anchor_lines` / `served` (every session) / `undo` (every depth) rows are purged in one transaction. A missing path is `already-absent` success and still reclaims residue; symlinks, directories and special files are refused; non-local backends fail closed. Under `workspace-write` the target must be inside the canonical writable roots. Reports logical bytes; physical disk returns at store shutdown (VACUUM). Partial failures are per path; there is no undelete. |
| `rename` | Renames or moves regular files and migrates their hashline state: `renames` (1–32 `{from, to}` pairs, exact duplicates collapse) run in request order, so chains work; each moved path's `anchor_meta` / `anchor_lines` / `served` (every session) / `undo` (every depth) rows move to the new path in one transaction, so anchors served before the rename stay usable without a re-read. Symlinks, directories and special files are refused; an existing destination is `[E_EXISTS]` unless it is the same inode under another spelling; non-local backends fail closed. The old path gets an absent observation, the new path a present one; no write intent. Partial failures are per pair; a store migration failure is a warning. |
| `apply_patch` | Applies **one change set** in one call — add, update, delete and move across several files — through `patch: string`. The patch is a sequence of file sections (`file` / `new` / `move` / `remove`) whose hunks (`replace` / `insert` / `delete`) are addressed by anchors from this session's read/grep/diff rows; the patch never repeats file content. The whole patch is checked first (syntax, shape, paths, anchors): a rejected patch lists every problem at once and changes nothing. Once applying starts, operations run in order and stop at the first failure — nothing is rolled back, so re-read the affected files before retrying. A completed add/update/move returns fresh anchors for the changed lines. |

### `apply_patch` — one change set in one call

`apply_patch` applies one change set in one call: **add**, **update**, **delete** and **move**
across several files. It is the multi-file counterpart of `edit` — single-file anchored
surgery stays `edit`.

A patch is a sequence of **file sections**; every content line is prefixed with `+`, so a
section header is the only thing that can appear un-prefixed:

```text
file src/greet.ts
replace Ab
+  return `hi ${name}`
replace Ef..Gh
+  if (!name) return "hi"
insert Cd
+
+// trailing note
delete Ij

new src/hello.ts
+export function hello(name: string) {
+  return `hi ${name}`
+}

move src/old.ts -> src/new.ts

remove src/dead.ts
```

Section headers: `file <path>` (update), `new <path>` (create), `move <from> -> <to>`,
`remove <path>` (delete). Hunk headers inside a section: `replace <anchor>` /
`replace <start>..<end>`, `insert <anchor>`, `delete <anchor>` / `delete <start>..<end>`.

The anchor addresses the line — **the patch never copies file content**. The anchors are the
ones from this session's read/grep/diff rows (`Ab`, `Ef`, `Gh`, `Cd`, `Ij` above), so a hunk
can only touch a line the model has actually seen.

Two phases: the **whole patch is checked first** (syntax, shape, paths, anchors) and a rejected
patch changes nothing; then the commit runs **in order and stops at the first failure**,
without rolling back what already landed — re-read the affected files before retrying.

**On by default.** `apply_patch.enabled: false` removes the tool and its guidance section.

### Output modes

`hashline.output_format` switches the model-facing text between:

- **`text`** (default) — the `ANCHOR:FILELINE` row format shown above;
- **`json`** — pure JSON envelopes (e.g. edit returns `{ ok, path, diff, hints, warnings }`
  where `diff` is a `{"<anchor>": content}` / `{"<anchor>:<line>": content}` dict — the
  key **is** the marker, so its shape follows the same `line_numbers` setting).
  Structured, for models that prefer parsing over row formats.

The web cards are unaffected — they render from `presentationMeta`, which is always
structured.

## Settings

All keys are the plugin's own **profile configuration** (dsh ≥ 0.1.7): edit them in the web at
**Settings → Plugins → `dsh-hashline-edittool`** — the ROW page's configuration form — and
commit through the page. Every change **hot-reloads**: it takes effect on the next tool
call, no restart. The native-editor view of the same values is the entry's `config:` block in
the profile patch (`$DSH_HOME/profiles/<name>/cordis.patch.yml`); all keys are optional:

```yaml
- id: dsh-hashline-edittool
  config:
    separator: "|"           # marker/content column separator (default ":")
    output_format: text      # "text" | "json"
    context_lines: 3         # context rows around stale echoes / diffs (0..20)
    max_response_chars: 48000 # per-response char budget (default 48000, clamp [8000, 49984])
    require_line_content: false
    line_numbers: false     # model-facing rows: `<anchor>:<line>` (true) or the bare anchor (false)
    ast:
      enabled: true
      languages: {}          # per-language { <id>: { enabled: false } } narrowing
    lsp:
      servers: {}            # named servers: { <languageId>: <command> }
      auto_diagnostics: true # deliver server diagnostics inline after writes
      idle_timeout_ms: 300000 # reclaim an idle language server after 5 min (0 = never)
    format:
      auto: off              # format mode after write/edit/ast_edit: off | full | range (legacy true→full, false→off)
      fallback_full_file: false # range mode: fall back to whole-file when the server lacks range formatting
      editorconfig: true    # honor .editorconfig indent settings when formatting (default on)
    write:
      preview: diff          # post-write echo: full | diff | confirmation (default diff)
    apply_patch:
      enabled: true          # offer the apply_patch tool + its guidance section (default on)
```

Migrating from 0.1.6? A pre-0.1.7 `~/.dsh/settings.yaml` is imported **by dsh itself**
during the upgrade. The plugin no longer reads, migrates, or writes any settings file —
the entry configuration on this page is the only source of truth. If an old `hashline:`
key did not carry over, set it in the form above.

### Per-preset guidance

The `tool:read` / `tool:edit` / `tool:grep` / `tool:undo_last_edit` / `tool:delete` /
`tool:rename` / `tool:apply_patch` guidance sections are plain-markdown override files in the
plugin's shared home, keyed by preset id — see
[`docs/adr/0001`](docs/adr/0001-guidance-override-files.md). Emptying a file resets to the
compiled default; a broken front-matter fence is fast-failed with a warning.

## Error Codes

| Code | Meaning |
| --- | --- |
| `[E_ACCESS]` | File exists but is not readable/writable. |
| `[E_ANCHOR_AMBIGUOUS]` | The anchor is live on multiple lines (a freed anchor was re-allocated while the model still held the old binding) — refused; re-read. Nothing was written for that file. |
| `[E_ANCHOR_STATE_DUP]` | The file's anchor state has duplicate anchors (corruption). The state is rebuilt; the edit is refused — re-read to get fresh anchors. Nothing was written. |
| `[E_ANCHOR_REALIGN]` | `grep` asked for anchors by file identity (#414), and the store's rows were written for different content: the file must be read and the allocation retried (the tool does that itself; the code is what the kernel answers). |
| `[E_SEARCH_UNAVAILABLE]` / `[E_SEARCH_FAILED]` / `[E_SEARCH_OVERFLOW]` | `grep`'s engine (#414): ripgrep is not installed / the search failed / the search's raw output crossed the 20 MB ceiling. There is no second engine; the error says to narrow pattern, path or include. |
| `[E_AST_DISABLED]` / `[E_AST_PATTERN]` / `[E_AST_TOO_LARGE]` | AST capability off for the language / pattern did not parse as one node / file exceeds the AST size cap. |
| `[E_AST_WORKER_ABORTED]` / `[E_AST_WORKER_FAILED]` | The tree-sitter worker was aborted / failed. |
| `[E_BAD_OP]` | Range end precedes range start (autocorrected when reversed). |
| `[E_BAD_REF]` | Anchor field is not a marker copied from a row's leftmost column; a salvageable pasted row is reduced to its anchor with a warning (all ops — `ins` keeps its anchor line). |
| `[E_BAD_SHAPE]` | Request/field shape wrong (unknown fields, wrong anchor field for the op, …). |
| `[E_BATCH_ABORT]` | A batch item failed; that file's batch was rejected and nothing was written for it. |
| `[E_BATCH_CONFLICT]` | Two items' ranges overlap on the same snapshot. |
| `[E_BARE_HASH_PREFIX]` | An anchor-prefixed row was pasted into `lines`; stripped with a warning. |
| `[E_CONTENT_MISMATCH]` | A declared `line` (require_line_content) does not match. |
| `[E_DELETE_UNSUPPORTED]` | `delete` cannot reach the path through the host filesystem (remote/non-local backend); use bash. |
| `[E_DELETE_STALE]` | The path changed after it was probed (approval window or fence); re-check and retry. |
| `[E_SYMLINK]` | `delete` refuses a symbolic link; the link and its target stay untouched. |
| `[E_NOT_FILE]` | `delete` target is a directory or a special file, not a regular file. |
| `[E_DELETE_FAILED]` | Whole-call fallback code when every path failed and no per-path domain code exists. |
| `[E_ELISION_IN_PAYLOAD]` | Payload carries the outline marker `…`; warning, edit proceeds. |
| `[E_FORMAT_EDITS]` / `[E_FORMAT_UNSUPPORTED]` | Formatting: the server's TextEdits were malformed (out-of-bounds/overlap) / the server has no document formatting provider — or an explicit `range` request hit a server without range formatting while `format.fallback_full_file` is off (#315). |
| `[E_GRAMMAR_BUILTIN]` / `[E_GRAMMAR_NO_DESCRIPTOR]` / `[E_GRAMMAR_UNKNOWN]` | Grammar catalog: built-in name collision / no descriptor for the language / unknown language. |
| `[E_GRAMMAR_FETCH_FAILED]` / `[E_GRAMMAR_HASH_MISMATCH]` / `[E_GRAMMAR_NOT_IN_TARBALL]` | Grammar download failed / SHA-256 mismatch / entry missing from the tarball. |
| `[E_HASH_SPACE]` | Anchor space exhausted (> 62⁸ lines). |
| `[E_INS_ANCHOR_DUP]` | `ins` `lines[0]` duplicates the anchor line; warning, proceeds. |
| `[E_INVALID_PATCH]` | Diff-preview markers pasted into `lines`; stripped with a warning. |
| `[E_LINE_HINT]` | A `<line>:<anchor>` hint disagreed with the anchor; anchor wins. |
| `[E_LINE_REF]` | A bare number was passed as an anchor; resolved via served state when safe. |
| `[E_LOSSY_BYTES]` | `edit` refuses a file whose read was lossy (bytes not valid UTF-8); a string write-back could not preserve the original bytes. |
| `[E_LSP_NO_SERVER]` / `[E_LSP_BAD_OPERATION]` / `[E_LSP_UNAVAILABLE]` | LSP: no server for the language / unknown operation / server unusable. |
| `[E_LSP_UNSUPPORTED_OPERATION]` / `[E_LSP_UNSUPPORTED_SYNC]` / `[E_LSP_BAD_ENCODING]` | LSP: the server never advertised the operation / it refuses `didOpen` so it cannot see the document a cursor points into / it negotiated a non-UTF-16 position encoding. All three refuse BEFORE a request is sent — “unsupported” is not “no results”. |
| `[E_LSP_UNEXPECTED]` | LSP: an unexpected failure outside the tool's own vocabulary, wrapped into it so the refusal still rides the error card instead of dying as non-lossless host output. |
| `[E_LSP_ABORTED]` / `[E_LSP_CLOSED]` / `[E_LSP_NOT_READY]` / `[E_LSP_TIMEOUT]` | LSP request aborted / channel closed / server still starting / timed out. |
| `[E_FORMAT_UNSUPPORTED]` / `[E_FORMAT_EDITS]` | Formatting: the server has no document formatting provider (or, for an explicit `range` call, no range provider with the fallback switch off, #315) / the formatter returned edits that cannot be applied (line out of range, overlapping). |
| `[E_NOOP_LOOP]` | The same edit keeps producing no change; resubmission rejected. |
| `[E_NOT_FOUND]` / `[E_NOT_TEXT]` | Missing file / directory-binary-non-UTF-8. |
| `[E_NOT_OBSERVED]` | File never observed this session (read-before-write policy). |
| `[E_RENAME_UNSUPPORTED]` | `rename` cannot reach the path through the host filesystem (remote/non-local backend); use bash. |
| `[E_RENAME_STALE]` | The source changed after it was probed (approval window or fence); re-check and retry. |
| `[E_CROSS_DEVICE]` | `rename` cannot move across filesystems (`EXDEV`); there is no copy+delete fallback. |
| `[E_EXISTS]` | `rename` refuses an existing destination that is not the same file under another spelling. |
| `[E_OP_INS]` | Informational: `ins` placed lines after the anchor. |
| `[E_PASTE_DUP]` | Replacement line matches an adjacent file line; kept verbatim. |
| `[E_PATCH_REFUSED]` | The whole patch was rejected before any change: the preflight collected every problem (syntax, shape, paths, anchors) into ONE refusal, and nothing was written. |
| `[E_SERVED_RECORD]` | Diagnostics: served state could not be persisted (storage failure); the response carries a re-read notice instead of silently losing the rows. |
| `[E_RANGE_STALE]` / `[E_RANGE_UNVERIFIED]` | Served-state verification failed; the range is echoed fresh. `E_RANGE_STALE` is also what the host's `FS_STALE_VERSION` is reported as: same remedy (re-read), different cause. |
| `[E_RESUME_CONFLICT]` | A read request carried both a resume token and `offset`/`limit`; the token already names the window — pass one or the other. |
| `[E_RESUME_GONE]` / `[E_RESUME_BAD]` / `[E_RESUME_TOOL]` | A `resume` token's spill is gone or expired / the token is malformed, corrupt, or bound to another session / it must be consumed by a different tool. |
| `[E_STALE]` | Anchor no longer matches served content; re-read. |
| `[E_SYNTAX_AFTER_EDIT]` | `ast_edit`'s replacement would leave the file unparsable; not written. |
| `[E_TOO_LARGE]` | The file exceeds the read budget (the `readBytes` ceiling); the budget itself is not configurable. Target a smaller file or split the content externally. |
| `[E_UNDO_STALE]` / `[E_UNDO_UNAVAILABLE]` | File changed after the edit / undo history could not persist. |
| `[E_WOULD_EMPTY]` | Edit would empty a non-empty file; use `write`. |
| `[E_WIN_REPLACE]` | Windows atomic replace held open by another process. |
| `[E_PARSE_FAILED]` | AST worker could not parse the document. |

## Store

Anchor identity, served rows, and undo history live in one SQLite store **keyed by the
workspace** being edited:

```
$DSH_HOME/plugins/dsh-hashline-edittool/<projectKey>/hash-store.sqlite
```

`<projectKey>` is a human-navigable encoding of the session's live working directory —
the one DSH reports for the session right now, so a session that moved into a worktree
gets its own store — so parallel workspaces never share anchors or undo history. Callers
outside a workspace fall back to the shared-home store. A 7-day TTL prunes served rows;
rebuilt automatically.

The `delete` tool purges a path's four row families (`anchor_meta`, `anchor_lines`, `served` across every session, `undo` at every depth) in one transaction and reports the logical bytes freed. When those bytes actually drop it writes `pending_vacuum`; shutdown runs a synchronous VACUUM and clears the marker only on success.

The `rename` tool moves a path's four row families to the new path in one transaction: the destination's residue is purged first, then each family is copied with `INSERT … SELECT` and the old rows deleted — never `UPDATE … SET path`, because the maintained path / row counts are `INSERT` / `DELETE` triggers. Anchors, undo bodies, `served.reported` and `updated_at` move verbatim, so the same anchors stay usable after the rename.

## Architecture

One plugin, three planes:

```text
src/
├── index.ts              # entry: mounts tools, settings, LSP, grammar routes
├── config.ts             # settings schema + wiring (hot-reload)
├── tools/                # the 10 tool entry points — thin, no IO of their own
├── domain/
│   ├── edit/             # edit engine, mutation transaction, contract, prompts
│   └── session/          # served state, hash store, file views
├── render/               # per-card projections: read / edit / grep cards, diff renderer
├── contract/             # request shapes + validation (schema is the single authority)
├── hashline/             # the anchor core: allocation, resolve/apply engine
├── infra/                # fs bridge, sandbox, paths, settings snapshot, workspace scope
├── lsp/                  # language-server sessions, auto-diagnostics
├── ast/                  # tree-sitter worker, grammar registry
└── guidance/             # per-preset override resolution + materialization
client/                   # the web card plugin (same package)
test/                     # 1,330 tests
```

Dependencies point one way: `tools → domain → render/contract → hashline/infra`. Cards
render from structured `presentationMeta`; the model text and the UI never parse each
other. See [`CONTEXT.md`](CONTEXT.md) for the domain glossary and [`docs/adr/`](docs/adr/)
for the decisions behind the contract.

## DSH Version Support

Compatibility is declared through peer dependencies
(`@deepseek-ai/schemastery >=3.18.3`, enforced by npm) and verified
against the harness this repository is actually run on:

| dsh version | plugin versions | notes |
| --- | --- | --- |
| **0.2.0-rc.2** (current build/test SDK line) | unreleased | devDependencies bumped to the 0.2.0-rc.2 SDK line with zero source changes; typecheck + both workspace suites green |
| **0.1.7-alpha.1** (prior SDK line) | **0.9.x** | settings are the plugin's Profile configuration (dsh ≥ 0.1.7), hot-reloaded on the next tool call; sparse lazy anchors; bounded anchor store |
| 0.1.6-alpha.1 (prior SDK line) | 0.7.1 – 0.8.x | dual event registration (`agent/created` + legacy `agent/session-start`); `systemPrompt` resolved as a scoped service |
| 0.1.5-rc.2 (live-verified) | 0.7.0 | unified anchor lifecycle, AST/LSP split, settings card, card gallery |
| ≥ 0.1.2-rc.0 | 0.6.x – 0.7.1 | self-rendered cards, per-workspace store, settings panel |
| 0.1.2 | 0.4.x – 0.5.x | v2 dynamic anchors; dsh 0.1.2 web-card adaptation completed (#69) |
| 0.1.2 (early) | 0.1.x – 0.3.x | legacy `line#hash` anchors, batch_edit |

- Build/test SDK line: `0.2.0-rc.2` (bumped after 0.10.1); `0.1.7-alpha.1` was the line from 0.9.0 (#134). 0.9.x requires dsh 0.1.7 — 0.8.x stays for 0.1.6. The 0.7.0 line was verified on dsh `0.1.5-rc.2`.
- Newer dsh 0.1.x/rc lines are expected to work; report regressions as issues.

## Development

```sh
npm run typecheck   # tsc --noEmit (src + test projects)
npm test            # vitest
npm run build       # clean lib/ + tsc + client workspace
```

Releases are **tag-first**: `npm run release -- X.Y.Z` bumps the version, moves the
changelog, tags `vX.Y.Z`, and pushes — the tag triggers the GitHub Actions release
workflow. `npm publish` is blocked until the tag exists. PRs first; `Closes #NN` in the
body. See [`.agents/skills/git-std.md`](.agents/skills/git-std.md).

## License

[MIT](LICENSE)

## Acknowledgments

This project is a **fork of
[**Rianico/dsh-better-edit**](https://github.com/Rianico/dsh-better-edit)**, now maintained
independently — thank you, [@Rianico](https://github.com/Rianico), for the foundation and
for putting hash-anchored editing in front of DeepSeek Harness users.

That fork itself stands on the hashline lineage, and this project is grateful to:

- [**pi-hashline-edit**](https://github.com/RimuruW/pi-hashline-edit) by RimuruW — the
  original pi-coding-agent extension that introduced content hashes and collision
  resolution;
- [**pi-hashline-edit-pro**](https://github.com/YuGiMob/pi-hashline-edit-pro) by YuGiMob —
  the hardened fork the hashline core here is ported from;
- Can Bölük's [*The Harness Problem*](https://stencil.so/blog/the-harness-problem) — the
  post that showed the harness, not the model, is the bottleneck.

Related reading: [Hash anchors + Myers diff + single-token anchors
(dirac.run)](https://dirac.run/posts/hash-anchors-myers-diff-single-token) and an
independent [hashline-vs-replace
benchmark](https://nwyin.com/blogs/hashline-vs-replace-edit-bench.html).

---

## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=hyperion2144/dsh-hashline-edittool&type=Date)](https://star-history.com/#hyperion2144/dsh-hashline-edittool&Date)

---

<p align="center">
  <strong>⭐ If hashline editing made your agent edit better, give it a star!</strong>
</p>
