DeepSeek Harness Plugin Hub

Publish and manage complete Harness Profiles. Discover Plugins for your next setup.

Explore

PluginsPresetsDocsNews

Community

Publish a pluginContactReport an issue

Resources

Plugin Hub on GitHubDeepSeek HarnessSystem statusPrivacy notice
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

Independent and unofficial. Not affiliated with, authorized by, or endorsed by DeepSeek.

DeepSeek Harness Plugin Hub
ProfilesPluginsCategoriesNewsDocsSign inManage Profiles
ProfilesPluginsCategoriesNewsDocsSign in
← Plugins

dsh-hashline-edittool

Hashline Edittool

Hash-anchored read/edit/write/grep/delete/rename/undo_last_edit/ast_grep/ast_edit/lsp/format tools for DeepSeek Harness (dsh). Every line is addressed by a variable-length content anchor (`<anchor>`, optionally `<anchor>:<line>`) so chained edits skip a r

The plugin will be installed here. Keep web if you are unsure.

npx -y @deepseek-ai/dsh plugin --profile web add dsh-hashline-edittool@0.11.0
READMECompatibilityVersions
hashline cards: read / edit / grep / LSP / settings

Compatibility and provenance

Hashline Edittool is published as dsh-hashline-edittool and currently resolves to version 0.11.0. The Hub verifies its manifest and preserves the exact installation source for reproducible installs.

DSH compatibility
*
Runtime surfaces
web
Release source
npm
Registry updated
11/4/2026

Versions

0.11.0stable
11/4/2026
0.10.7stable
11/2/2026
0.10.6stable
10/30/2026
Show 28 more versionsCollapse versions
0.10.5stable
10/29/2026
0.10.4stable
10/29/2026
0.10.3stable
10/19/2026
0.10.2stable
10/16/2026
0.10.1stable
10/8/2026
0.10.0stable
10/7/2026
0.9.5stable
9/29/2026
0.9.4stable
9/26/2026
0.9.3stable
9/25/2026
0.9.2stable
9/25/2026
0.9.1stable
9/23/2026
0.9.0stable
9/23/2026
0.8.1stable
9/20/2026
0.8.0stable
9/19/2026
0.7.3stable

Related plugins

Loading related plugins…

Latest
0.11.0
DSH
*
HMR
Process restart
Tree shaking
Safe tree shaking not declared
Unpacked size
3 MB
Files
323
Surface
web
License
MIT
Source
npm
GitHub
★ 1
Weekly downloads
1,051
Last push
11/4/2026
View source ↗Project homepage ↗
README badge

Click the badge to copy Markdown for your README.

Do you maintain this Plugin?Claim benefit · Priority security scan

Verify the GitHub repository declared in package.json to manage this listing. After you claim it, Hub will prioritize a security scan of the current version and publish the result when it passes.

Claim this Plugin →
Report an issue
9/18/2026
0.7.2stable
9/17/2026
0.7.1stable
9/16/2026
0.7.0stable
9/16/2026
0.6.2stable
9/16/2026
0.6.1stable
9/15/2026
0.6.0stable
9/14/2026
0.5.2stable
9/10/2026
0.5.1stable
9/10/2026
0.5.0stable
9/10/2026
0.4.3stable
9/7/2026
0.4.2stable
9/6/2026
0.4.1stable
8/30/2026
0.4.0stable
8/22/2026

Related plugins

More verified plugins in developer-tools.

Web App@deepseek-ai/dsh-web-appThe dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)Sdk Minimal@deepseek-ai/dsh-sdk-minimalThe standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessionsSdk App@deepseek-ai/dsh-sdk-appThe dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-baseExperimental Inspector@deepseek-ai/dsh-experimental-inspectorExperimental cross-realm CDP hub for Host debugging and Client Runtime inspection

README

dsh-hashline-edittool

hashline cards: read / edit / grep / LSP / settings

Line-anchored editing for DeepSeek Harness
Every line gets a variable-length content anchor — no line numbers, no echoing old code, fewer tokens, more context for real work.

English · 简体中文

Quick Start • The Anchor Contract • Tools • Settings • Error Codes • Architecture • Acknowledgments


What it is

A DeepSeek Harness 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

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:

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)
Profilea dsh profile (dsh plugin initializes one on first use)
Backendssandboxed / 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):
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:

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

opanchor fieldbehavior
replaceanchor_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.
insanchor_afterinsert 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.
delanchor_start (+ optional anchor_end)delete the range (or the single anchor_start line); lines is ignored.
sedanchor_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

ToolWhat it does
readFile 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).
editOne or more range edits via { path?, edits: [{ op, … }, …] } — the full contract is above. 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.
writeFully 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.
grepRipgrep regular-expression search (or regex: false for literal) across a path tree, one section per file under the same header, full lines only. with ripgrep's own ignore rules (#414): a build directory a parent excludes is never walked, and ripgrep produces the rows ( + ). 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 names the cut.

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:

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:

- 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. Emptying a file resets to the compiled default; a broken front-matter fence is fast-failed with a warning.

Error Codes

CodeMeaning
[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]

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:

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 for the domain glossary and 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 versionplugin versionsnotes
0.2.0-rc.2 (current build/test SDK line)unreleaseddevDependencies 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.xsettings 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.xdual event registration (agent/created + legacy agent/session-start); systemPrompt resolved as a scoped service
0.1.5-rc.2 (live-verified)0.7.0unified anchor lifecycle, AST/LSP split, settings card, card gallery
≥ 0.1.2-rc.00.6.x – 0.7.1self-rendered cards, per-workspace store, settings panel
0.1.20.4.x – 0.5.xv2 dynamic anchors; dsh 0.1.2 web-card adaptation completed (#69)
0.1.2 (early)0.1.x – 0.3.xlegacy 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

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.

License

MIT

Acknowledgments

This project is a fork of Rianico/dsh-better-edit, now maintained independently — thank you, @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 by RimuruW — the original pi-coding-agent extension that introduced content hashes and collision resolution;
  • pi-hashline-edit-pro by YuGiMob — the hardened fork the hashline core here is ported from;
  • Can Bölük's 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) and an independent hashline-vs-replace benchmark.


Star History

Star History Chart


⭐ If hashline editing made your agent edit better, give it a star!

One rg invocation walks the path
.gitignore
match
context
truncatedBy
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_grepStructural 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_editStructural 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.
lspSymbol-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.
formatFormats 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.
deleteDeletes 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.
renameRenames 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_patchApplies 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.
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.