# omp-edit [oh-my-pi](https://github.com/can1357/oh-my-pi)-style editing for the DeepSeek Harness (DSH). It **replaces DSH's built-in `edit` tool** with oh-my-pi's *hashline* patch language. It also wraps `read` and `grep` so their output carries the content-hash tags the patches anchor on. The model points at line numbers of a tagged snapshot instead of retyping the old text. That avoids whitespace-mismatch "string not found" loops and saves output tokens. A stale snapshot is detected before anything is written, and is rebased automatically when the anchored lines survived. ## What the agent sees `read` output carries a `[PATH#TAG]` header and `LINE:TEXT` rows (the 4 hex characters are a hash of the whole file): ``` [src/app.py#1A2B] 1:def greet(name): 2: print("Hello, " + name) ``` `grep` groups matches under a tagged header per file. A grep hit counts as having read the file, so it can be edited straight away: ``` [src/app.py#1A2B] 2: print("Hello, " + name) ``` `edit` takes one `input` string with one or more file sections: ``` [src/app.py#1A2B] PUT 2.=2: + print(f"Hi, {name}") ``` It returns the fresh tag plus a compact numbered preview of the changed region, so the agent can chain edits without re-reading. The Web UI shows the usual diff card. ## Hashline operations | Form | Effect | | --- | --- | | `PUT N.=M:` + `+rows` | Replace original lines N..M (`N.=N` for one line) | | `PUT N*:` + `+rows` | Replace the syntactic block opening at line N (function/class/object, Markdown heading section, Python/YAML indented block; leading decorators included) | | `PUT N:` | Insert before / after line N (`<1` = file head, `>$` = file tail) | | `PUT >N*:` | Insert after the block opening at N | | `CUT N.=M [@name]` / `CUT N* [@name]` | Delete and capture (anonymous register, or a named one) | | `PUT N [@name]` | Paste a register at a gap | | `PUT N.=M @name` / `PUT N* @name` | Paste a named register over a range/block | | `REM` | Delete the file | | `MV DEST` | Move the file after the section's edits | The rules match oh-my-pi: - Body rows are `+TEXT`; a lone `+` is a blank line; literal `-x` is written `+-x`. - All numbers refer to the tagged snapshot, never shifted by earlier hunks. - Overlapping ranges are rejected. - Named registers persist for the session, and cross-file moves work (`CUT 1* @fn` in one section, `PUT <1 @fn` in another). - The whole call is validated before anything is written. - Tolerant parsing: an optional `*** Begin/End Patch` envelope and markdown fences are stripped, and `PUT 3-5:` / `PUT 3:` are accepted with a warning. ### Stale tags Every tagged `read`/`grep`/`edit` records a snapshot. When the file changed since the tag (another tool, a formatter, the user): - if every anchored line still exists unchanged, with a neighbour that moved by the same offset, the patch is **remapped onto the current file** and applied. A warning says so. - otherwise the edit is **rejected without writing**, with a message to re-read. A tag that was never issued for that file (e.g. copied from another file) is rejected with a hint. ## Paging: read, grep and glob Like oh-my-pi, outputs are cut into pages so the model never pulls a whole file or huge search into context at once: - **`read`**: at most **200 lines** per call (`limit` above 200 is capped); the footer gives the `offset` to continue. - **`grep`**: at most **200 matches** per call, grouped under `[PATH#TAG]` headers. Pass `offset` for the next page (`… call grep again with offset=200`). - **`glob`**: at most **200 paths** per call, with the same `offset` cursor. Change the page size with `pageLines`. ## Guard rule A `tools/pre-execute` listener makes the agent use these tools instead of working around them through the shell. Denied calls get a short reason that names the right tool. | Denied | Use instead | | --- | --- | | `sed -i`, `perl -i`, `ed`/`ex`, `tee`, `>`/`>>` into files, heredocs, `truncate`/`dd` | `edit` (hashline) | | Inline scripts that can touch files, processes or the network (`node -e "require('fs')…"`, `node --input-type=module --eval "import …"`, `python3 -c "open(…)"`, `ruby -e "File.write…"`, `deno eval`, `bun -e`, `php -r`…), or scripts piped in through stdin (`… \| node`, `node - < f`) | `read`/`grep`/`edit` | | `cat`/`head`/`tail`/`less`/`bat`/`nl FILE`, `sed -n`, `awk` | `read` (paged) | | `grep`/`rg`/`ag`/`ack` over files | `grep` (paged, tagged) | | `find`/`fd`/`locate`, `ls -R` | `glob` | | `str_replace_editor`, `apply_patch` tools; `write` on an **existing** file | `edit` (`write` stays allowed for brand-new files) | What stays allowed: - Text inside quotes is ignored (`git commit -m "fix > bug"`). - Redirection to `/dev/null` is fine. - Filters on piped output keep working (`npm test 2>&1 | tail -5`, `ps aux | grep node`). - Computation-only one-liners: `node -e "console.log(1+1)"`, `node -p "2**10"`, `python3 -c "print(sum(range(9)))"`, `ruby -e 'puts 1+1'`. Running a file is a normal command (`node --test test/`, `node build.mjs`). How inline scripts are judged: the guard works out which interpreter runs (also through `env`, `sudo` and absolute paths) and pulls out its program from `-e`/`-p`/`--eval`/`--print`/`--eval=`/`-c`/`-r`/`eval`. The program is allowed only if it contains **none** of the identifiers that start any I/O path, for example: - JavaScript: `require`, `import`, `process`, `globalThis`, `eval`, `Function`, `constructor`, `fetch`, `Deno`, `Bun`, or bracket access with a string (`x['y']`); - Python: `import`, `__import__`, `open`, `os`, `subprocess`, dunder attributes; - Ruby/Perl: `File`, `IO`, `system`, `open`, backticks. Banning the identifiers that start an I/O path covers obfuscation too: `fs['write'+'File']` still needs `require` or `import` to get `fs`. A program the guard can't read (stdin, `-`) is denied. If the guard can't check whether a path exists, `write` is denied rather than allowed (it fails closed). Turn the guard off with `guard: false`, or change the list of blocked tools with `guardTools`. ## Diff cards in the Web UI The plugin ships a web client (`client.js`) that registers an `edit` tool view. Hashline, sloppy and apply_patch calls would otherwise show raw patch JSON; instead they render with the **same chrome as DSH's own tool rows**, built from DSH's primitives (`DisclosureRow`, `TextShimmer`, `Tooltip`, its icons) with CSS copied from the stock `ToolRow`, `CodeCard` and `DiffBlock` modules: - the standard 24px row: edit icon, `Edit` title, a dot separator, the file path as a dotted link that opens the file, and a small monospace `+N -M` that turns green/red on hover or when expanded. Rows start collapsed and follow the turn's expand/collapse state, like every other tool; - a failed edit shows its first error line in red, and expanding it shows the standard **Output** card; - the expanded diff is the stock code card: a language header with the wrap and copy icon buttons, 22px-padded rows with the inset +/- bar, and the head/tail fold after 9 rows; - one addition: a **line-number gutter** with the old and new file line of every row. Colors come only from DSH theme tokens, so the view follows the active theme. The host attaches `oldStart`, `newStart` and `rows` to every hunk in `meta.diffs`, next to the standard `path`/`oldText`/`newText` fields. Older results without them still render, just without numbers. ## Edit modes `mode` picks the wire format of the replacement `edit` tool: | Mode | Input | Notes | | --- | --- | --- | | `hashline` (default) | `input` | Described above | | `replace` | `file_path`, `old_string`, `new_string`, `replace_all`, or `edits: [{old_string,new_string,replace_all}]` | Fuzzy whitespace matching: exact → indentation/trailing-space-insensitive → whitespace-collapsed → smart quotes/dashes → ≥95% similarity. The file's indentation is carried over. Batched edits are atomic | | `sloppy` | `input` | `*** Edit File:` / `*** Find` / `*** Replace` · `*** Insert Before` · `*** Insert After`, `…` captures, ` all` suffix | | `apply_patch` | `input` | Codex format: `*** Add/Delete/Update File`, `*** Move to`, `@@` scoped hunks, `*** End of File` | **Per-model selection**, like oh-my-pi: 1. `modelVariants` (substring of `provider/model` → mode), first match wins. 2. `mode`. 3. With `hashline` and `strict: false`, model families oh-my-pi considers unreliable at hashline fall back to `fallbackMode` (default `replace`). The list is `fallbackModels`, default `kimi, mimo, minimax, deepseek, stepfun, codex-spark, glm-5.3-flash`. The tool schema follows the model the agent actually uses and switches mid-session when you change models. ## How it replaces the built-ins DSH registers `edit`/`read`/`grep` in each agent preset. Tools registered in an agent's own scope shadow those, so this plugin listens for `agent/created` and registers into every agent's scope: - **`edit`** is replaced outright. Nothing of DSH's `edit` is used. - **`read`** and **`grep`** delegate to DSH's implementations (sandbox, limits, ripgrep, spill files all unchanged) and only add tags to the output. DSH's safety rules are kept: - **Read first:** you still need to `read` (or `grep`) a file before editing it. This is `fs-observation-policy`. - **Guarded writes:** writes go through `ctx.fs` with the version guard. - **Sandbox:** the session's sandbox policy applies to writes and `REM`. - **Line endings:** CRLF files stay CRLF. To use DSH's original tools again, disable this plugin (Settings → Plugins → OMP Edit) or set `replaceEdit/replaceRead/replaceGrep: false`. ## Installation ```bash dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-edit.git ``` Or in the Web UI, go to **Settings → Plugins → Install** and paste `git+https://git.rizzy.dev/DSH-Plugins/omp-edit.git`. You can also ask the agent to install it with `plugin_manager` → `install_bundle`. For development: ```bash git clone https://git.rizzy.dev/DSH-Plugins/omp-edit.git ~/dsh-plugins/omp-edit dsh plugin --profile web add link:$HOME/dsh-plugins/omp-edit ``` Then restart DSH (`systemctl --user restart dsh` or re-run `dsh web`). New sessions get the new `edit` tool. Sessions that already exist pick it up once they're re-created, e.g. after the restart. No dependencies: everything is plain Node.js (≥ 20). ## Configuration Set options in the `config` of the row in `~/.dsh/profiles/web/cordis.patch.yml`: ```yaml - id: omp-edit config: mode: hashline # hashline | replace | sloppy | apply_patch strict: false # true: never fall back from hashline fallbackMode: replace fallbackModels: [kimi, mimo, minimax, deepseek, stepfun, codex-spark, glm-5.3-flash] modelVariants: # substring of "provider/model" -> mode gpt-5-codex: apply_patch replaceEdit: true replaceRead: true replaceGrep: true fuzzy: true # replace mode fuzzy matching replaceGlob: true pageLines: 200 # read/grep/glob page size guard: true # deny shell edits/reads/searches and non-hashline write tools guardTools: [write, str_replace_editor, apply_patch] # write: only existing files are denied ``` ## Tests ```bash npm test # node --test test/*.test.mjs — 34 tests (engine + integration on the real DSH fs-local backend) ``` ## Files | File | Role | | --- | --- | | `index.js` | DSH plugin: per-agent tool registration, mode selection, fs policy, paging, guard rule | | `client.js` | Web client: `edit` tool view rendering hashline/patch results with DSH's native tool-row and diff-card chrome | | `lib/hashline.js` | Hashline parser and applier | | `lib/block.js` | `N*` block resolution (brackets, indentation, Markdown, decorators) | | `lib/store.js` | Session snapshot store, Myers line diff, stale-tag rebase | | `lib/replace.js` | Fuzzy `replace` mode | | `lib/sloppy.js` | `sloppy` mode | | `lib/apply_patch.js` | `apply_patch` mode | | `lib/diff.js` | Hunk diffs for the UI diff card | | `lib/prompts.js` | Model-facing tool descriptions (adapted from oh-my-pi's `crates/pi-edit/prompts`) | Pairs well with [omp-lsp](https://git.rizzy.dev/DSH-Plugins/omp-lsp), which appends language-server diagnostics to every edit.