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) |
| 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):
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:
- 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;
- the anchor is in this session's served set;
- 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. 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. 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
| 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] |
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 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
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!