pi-multiedit0.4.1
Transactional multi-file edit tool for pi: text, regex, line-anchor, ast-grep and JSON selectors in one atomic call
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-multiedit
One edit call for a whole change in pi. An agent can rename a function across a
codebase, create a module and register it, add an entry to a JSON registry and rewrite a section
of docs, all in a single edit call that applies everything or nothing. What comes back is what
landed on disk, line by line.
pi install npm:pi-multiedit
It replaces pi's read and edit tools and withdraws write (even if --tools names it), since
an edit without a selector creates or overwrites a file. Remove pi-hashline-edit if you have it installed: both register
read and edit.
Why it's built this way
Agents edit files badly in predictable ways. The usual edit tool changes one file per call, so a
ten-file change takes ten calls, or the agent writes a throwaway Python script to do it. Those
scripts fail halfway and leave half a change on disk. Exact-text matching breaks on a trailing
space the model didn't remember. After editing, the agent runs git diff or cat to see what
happened. pi-multiedit is built so that none of this is needed.
- All or nothing. Every edit in a call is checked in memory before anything is written. If any one fails, no file changes, and the agent gets every failure at once with the nearby lines to retry from. Writes are atomic per file, and if a write fails halfway through a call, the files already written are restored.
- Point at lines instead of retyping them.
readtags each line with a short anchor, like12#KT:. An edit can name a range by its anchors (from: "12#KT", to: "15#BH"), so the model doesn't reproduce text it might get slightly wrong. Anchors stay valid through the earlier edits in the same call, and a stale one is refused instead of hitting the wrong line. - The right selector for the job. Exact text for small changes, anchor ranges for blocks, regex for patterns, and JSON pointers for data files. Any of them can apply to one file or to every file matching a glob.
- The result is the proof. Files are re-read after writing and compared byte for byte with
what was intended. The result shows only the lines that changed, as word diffs with fresh
anchors the next call can use. There's nothing left to check with
git diff. - No new syntax errors. Every edited file with a known grammar is parsed before and after. An edit that breaks parsing is refused, and the broken lines come back with anchors. Errors that were already in the file don't count.
- One concept, no overlapping options. Every change is an edit: a scope, at most one selector, an action and a count. Without a selector, an edit applies to the whole file, which is how files are created, appended to or deleted. There is no second syntax and no alias. The description covers every field and ends with a complete example; in evals, that brought tool errors to zero where looser variants had several.
The two tools
| Tool | What it does |
|---|---|
read{path, offset?, limit?} |
Returns text with an anchor on every line (12#KT: return total). The hashes are compatible with pi-hashline-edit. Images and other non-text files use pi's built-in read. |
edit{edits, allowSyntaxErrors?} |
Applies every edit in order, all or nothing. |
Each edit has a scope, at most one selector, and optionally an action and a count:
| Options | |
|---|---|
| scope | path, or glob over git-visible files (e.g. src/**/*.ts) |
| selector | old: exact text · from + to: whole lines between two anchors, inclusive (an end may also be exact text, or a bare line number of a line this session has shown, by read, edit or numbered shell output such as cat -n and rg -n, checked against that view like an anchor) · regex (+ flags), where new may use $1 and $<name> · json: a pointer whose segments may be [key=value] or - (append) · none: the whole file (path only) |
| action | replace (default), before, after, delete. Every action except delete needs new; deleting is always explicit |
| count | how many matches are expected across the scope: 1 (default), a number, or "all" |
new is text for every selector except json, where it is the JSON value itself ("integration",
3, {"path": "a"}). The action composes with every selector the same way. On the whole file,
replace creates or overwrites it, before/after prepend or append raw text to an existing
file, and delete removes it.
A single call that changes code, creates a file, updates a JSON registry and every matching call site:
{"edits": [
{"path": "src/accounts.ts", "from": "12#KT", "to": "15#BH", "action": "delete"},
{"path": "src/accounts-api.ts", "new": "export const accountsApi = {};\n"},
{"glob": "src/**/*.ts", "old": "repo.find(", "new": "repo.findOrThrow(", "count": "all"},
{"path": "test/layers.json", "json": "/suites/-", "new": {"path": "test/accounts-api.test.ts", "layer": "integration"}}
]}
Moving a file is not an edit; use git mv.
What comes back
On success, the files that changed, with only their changed lines. ~ marks a rewritten line,
+ an added one, and each carries its new anchor:
Applied 2 step(s) to 2 file(s); re-read from disk: identical; syntax: ok (2 file(s)).
step 1: 2 matches in 1 file(s)
src/accounts.ts +2 -2
@@ 4
~4#TS: return repo.[-find-]{+findOrThrow+}("users", id);
@@ 8
~8#QM: return repo.[-find-]{+findOrThrow+}("companies", id);
test/layers.json +2 -1
@@ 3
~3#HR: { "path": "test/accounts.test.ts", "layer": "unit" }{+,+}
+4#QJ: { "path": "test/accounts-api.test.ts", "layer": "integration" }
On failure, nothing is written, and every failing step is listed with what the agent needs to fix it in one retry:
Nothing was written: 2 of 2 step(s) failed.
step 1 (src/accounts.ts): old not found in src/accounts.ts
nearest:
2#KM:
3#HW:export function fetchUser(id: string) {
4#YR: return repo.find("users", id);
nearest:
6#SY:
7#JV:export function fetchCompany(id: string) {
8#WN: return repo.find("companies", id);
step 2: old matched 2 times, expected 1. Add surrounding context, or set count to 2 or "all".
src/accounts.ts: lines 4, 8
An edit that would leave a file unparsable is refused the same way:
Nothing was written: the edit introduces parse errors. Fix them in the retry, or set allowSyntaxErrors if the parser is wrong.
New parse errors near:
src/accounts.ts
9#WN: return repo.find("companies", id);
10#BN:}
When old doesn't match exactly, the tool tries again ignoring trailing whitespace and curly
quotes, then a consistent indentation shift (re-indenting new to match). It does so only when
the match is unique, and the result says when it happened.
How it compares
A small synthetic eval at low thinking: 4 tasks, each run twice per setup. The tasks: a multi-file
TypeScript change with a JSON registry and docs; a Python rename that adds a parameter; a
TypeScript config and docs change; and converting 9 multi-line, nested assertEqual(a, b) calls
across 3 test files to expect(a).toEqual(b), next to look-alikes that must not change. Each model
ran in its own vendor's agent with its native edit tool, and in pi with each edit tool.
claude-opus-5-5
| Setup | Tool calls | Edit calls | Shell edits | Tokens in | Tokens out | Time |
|---|---|---|---|---|---|---|
| Claude Code | 28 | 6 | 8 | 250k | 9.0k | 117s |
| pi built-in | 32 | 8 | 7 | 155k | 7.8k | 111s |
| pi-hashline-edit | 21 | 1 | 9 | 162k | 6.3k | 99s |
| pi-multiedit | 28 | 9 | 0 | 160k | 6.6k | 114s |
claude-sonnet-5-5
| Setup | Tool calls | Edit calls | Shell edits | Tokens in | Tokens out | Time |
|---|---|---|---|---|---|---|
| Claude Code | 35 | 6 | 8 | 265k | 9.7k | 122s |
| pi built-in | 32 | 5 | 11 | 169k | 7.8k | 105s |
| pi-hashline-edit | 27 | 4 | 7 | 173k | 7.1k | 99s |
| pi-multiedit | 24 | 8 | 0 | 142k | 6.6k | 84s |
gpt-6-sol
| Setup | Tool calls | Edit calls | Shell edits | Tokens in | Tokens out | Time |
|---|---|---|---|---|---|---|
| Codex | 42 | 9 | 0 | 805k | 6.8k | 232s |
| pi built-in | 62 | 26 | 2 | 122k | 5.5k | 200s |
| pi-hashline-edit | 57 | 24 | 0 | 137k | 4.0k | 150s |
| pi-multiedit | 36 | 10 | 0 | 90k | 3.8k | 146s |
gpt-6-luna
| Setup | Tool calls | Edit calls | Shell edits | Tokens in | Tokens out | Time |
|---|---|---|---|---|---|---|
| Codex | 28 | 7 | 1 | 689k | 5.5k | 163s |
| pi built-in | 58 | 23 | 1 | 131k | 4.8k | 151s |
| pi-hashline-edit | 63 | 20 | 5 | 211k | 6.1k | 199s |
| pi-multiedit | 40 | 9 | 0 | 106k | 3.9k | 128s |
Every setup passed all 8 tasks except gpt-6-luna with pi built-in and with pi-hashline-edit (7/8
each; both failures were Python scripts on the assertEqual task). Claude Code and Codex are each
model's own agent with its native edit tool.
"Shell edits" counts files changed with sed -i or Python scripts instead of the edit tool:
no checks, no feedback, and the way half-applied changes happen. pi-multiedit is the only setup
with none for every model. The GPT models gain the most: with gpt-6-sol it needed about 40% fewer
tool calls than pi's other edit tools and the fewest input and output tokens of all four setups.
Claude models already batch their edits, so their numbers are closer; with them, pi-multiedit's
main gain is that every edit goes through the tool instead of sed. Input tokens include cached
input, which the native agents use heavily (Codex served about 83% from cache). The pi-multiedit
rows are from this version, with pi's write tool withdrawn. Two runs per task show a direction,
not a significant result: a single extra turn moves a model's totals by several percent.
Line numbers from the shell
Models often read files through bash, with cat -n, nl, rg -n or grep -n, and then edit by the
line numbers they saw there. Each numbered line of a bash result is matched to a file, either by the
path on the line or by a path in the command or output, and is recorded only when that file has exactly
that text at that number. A bare number in from/to then resolves like one from read, stale check
included. Unnumbered output, such as sed -n 10,20p, is not recorded, because the model would have to
count the lines itself.
For other extensions
A file that another extension puts in front of the model whole, with N#HH anchors, can count as
read: emit anchored-view on pi's event bus with the absolute path and the exact text shown.
pi.events.emit("anchored-view", { path: "/abs/path/to/file.ts", text });
Bare line numbers in from/to then resolve against that view, with the same stale check as an
anchor. The hashes are pi-hashline-edit's, so any implementation of them produces valid anchors.
Drafts
A draft holds edits in memory instead of writing them, and applies them later as one all-or-nothing commit. It is for runs whose edits must not touch the user's files yet: a speculative turn, or review before write.
Inside a draft, an edit call is planned against the draft's view of the workspace: disk, with the
draft's own changes on top. Files it created exist there, and globs see them; files it deleted do
not. Each call gets the same checks, failures and syntax check as a normal edit. A failing call
leaves the draft as it was. The result text is a normal edit result, except that drafted, not written takes the place of re-read from disk: …, and details.written is false. For each file,
the draft keeps the disk content from the moment it first touched the file, along with the latest
content. Reading in a draft shows the drafted content, with the same anchors and paging as read.
Each draft has its own record of what the model has seen. Line numbers seen in a draft never
validate a bare line number in the main session, and line numbers seen in the main session never
validate one in a draft. anchored-view events and numbered shell output count for the main
session only.
Applying commits the drafted files under the same check as a normal edit: no file may have changed since the draft read it. If one has, the draft's edit calls are replayed in order against the current disk, and the replayed result is committed if every call still applies. Bare line numbers in those calls are pinned to the anchors they meant when drafted. If any call no longer applies, for example because its anchor's line changed, nothing is written and the error names the files that moved. After a successful apply, the draft is gone and the written files count as shown in the main session.
API (src/draft.ts)
import { openDraft, draftEdit, draftRead, applyDraft, discardDraft, draftSummary, materializeDraft, getDraft } from "pi-multiedit/src/draft.ts";
const id = await openDraft(cwd); // a new, empty draft over cwd
await draftEdit(id, { edits, allowSyntaxErrors? }); // {content, details}; throws the edit error text
await draftRead(id, { path, offset?, limit? }); // {content, abs, fileText}: N#HH lines as read shows them,
// the canonical path, the whole file's raw text
draftSummary(id); // {files: [{path, status: "created"|"modified"|"deleted", diff}], insertions, deletions}
await materializeDraft(id, dir); // writes drafted files into dir (same relative paths), deletes drafted deletions;
// refuses, writing nothing, if a drafted file is outside cwd
await applyDraft(id); // {content, details}: "Applied … ; re-read from disk: …"; throws with nothing written
discardDraft(id); // true if the draft existed
getDraft(id); // {id, cwd, changes: Map<abs, {abs, before, after}>, requests, shown} | undefined
Operations on one draft run in the order they were called. An unknown id is an error.
Event channel
An extension that does not import pi-multiedit uses pi's event bus. The request goes on
multiedit:draft, and the reply comes back through the reply callback in the payload. reply
is called exactly once, also when the request fails.
pi.events.emit("multiedit:draft", { op: "open", cwd, reply: (r) => … });
| op | request | reply |
|---|---|---|
open |
{cwd} (absolute) |
{draftId} |
edit |
{draftId, params: {edits, allowSyntaxErrors?}} |
{content, details} (the edit tool's result) or {error} |
read |
{draftId, path, offset?, limit?} |
{content, abs, fileText} or {error}: abs is the canonical path, fileText the whole file as drafted (or on disk) |
apply |
{draftId} |
{ok: true, content, details} or {ok: false, error} |
discard |
{draftId} |
{ok: true} or {error} |
summary |
{draftId} |
{files, insertions, deletions} or {error} |
materialize |
{draftId, dir} (absolute) |
{ok: true} or {ok: false, error} |
An unknown op or draft replies {error}; for apply and materialize the reply is {ok: false, error}.
On load the extension emits multiedit:draft-ready with {version: 1}. A listener registered
later misses that event, so it can send any request instead: if pi-multiedit is loaded, reply is
called.
Limits
- Text files in valid UTF-8 only. Binary files and other encodings are refused, never rewritten.
- A consistently CRLF file keeps CRLF. In a file with mixed line endings, unedited lines keep
theirs. Edited lines are written with LF, and a multi-line
oldonly matches loosely there (the result says so). - Writes go through symlinks to the file they point to. Deleting a symlink itself is refused; use bash for links.
- Each write replaces the file with a new one (temp file and rename). The file mode is kept, but hard links break and the owner and extended attributes are not kept.
- The syntax check covers TypeScript, JavaScript, CSS, HTML and Python out of the box, chosen by
file extension. Python's parser is bundled (prebuilt for macOS and Linux on x64 and arm64,
and Windows x64), so installing runs no dependency install scripts. Other languages (Go, Rust, Java, Ruby and more) need their
grammar:
npm i @ast-grep/lang-<name> --prefix ~/.pi/agent/npm. Without it, the syntax check skips those files.
How it works
A call is planned entirely in memory. Each edit runs against the current in-memory state of its
files, in order, and records which lines it changed. That record is what lets anchors from your
last read still find the right lines after earlier edits in the same call have moved them. If
any edit fails, the plan is thrown away.
Planning takes no locks. Before writing, every file is checked against what was read during planning. If another agent, an editor or a formatter changed one in the meantime, the whole call is planned again against the new content, up to three times. Edits whose text is still there apply on top of the other change, and the result says so. An edit whose target was changed fails like any miss.
Writing is serialised within the pi process: parallel edit calls commit one after the other, for the few milliseconds each takes, so neither can drop the other's change. Each file is checked once more just before its own write, written to a temporary file and renamed into place; if a write fails, the files already written are put back, unless something else has changed them since. Finally every file is read back and compared with what was intended. One limit remains: another program writing a file in the brief window between that last check and the rename (while the temporary file is written) is overwritten without notice. Filesystems offer no way to replace a file only if it is unchanged.
Requirements
- pi 1.0.3 or newer, on Node 22.19 or newer.
Development
npm install
npm test # unit, property (fast-check) and tool tests; no model calls
npm run typecheck # tsc -p .
Credits
Hash-anchored lines come from oh-my-pi, and the hashes match pi-hashline-edit. The syntax check parses with ast-grep's tree-sitter bindings.