pi-mono-multi-edit
Pi extension that replaces edit with batch edits and Codex-style patches
Package details
Install pi-mono-multi-edit from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-mono-multi-edit- Package
pi-mono-multi-edit- Version
1.7.2- Published
- Apr 23, 2026
- Downloads
- 1,591/mo · 214/wk
- Author
- emanuelcasco
- License
- unknown
- Types
- extension
- Size
- 126.5 KB
- Dependencies
- 1 dependency · 4 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
Multi-Edit — Enhanced Edit Tool
A pi extension that replaces the built-in edit tool with a more powerful version that supports batch edits across multiple files and Codex-style patch payloads — all validated against a virtual filesystem before any real changes are written.
Origins
Initially derived from mitsuhiko/agent-stuff's pi-extensions/multi-edit.ts. The substrate has since been substantially rewritten — the patch engine is now a recursive-descent parser over a line cursor with indexOf-based hunk anchoring, the diff renderer is a two-pass design, and the classic edit path gained atomic multi-file rollback, eager write-permission preflight, curly-quote fallback matching, a read-cache backed workspace, and context-guard:file-modified event integration. Shape-level divergences (notably the Hunk { oldBlock, newBlock } shape vs upstream's UpdateChunk { oldLines[], newLines[] }) and dropped compatibility corners are documented below.
Overview
The standard edit tool handles one oldText → newText replacement at a time. Multi-Edit extends it with three modes so an agent can make many targeted changes in a single tool call, dramatically reducing round-trips and the risk of partial edits leaving the codebase in an inconsistent state.
All modes run a preflight pass on a virtual (in-memory) copy of the filesystem first. If any replacement fails, no real files are touched.
Modes
1. Single (classic)
Identical to the built-in edit tool. Provide path, oldText, and newText.
{
"path": "src/index.ts",
"oldText": "const foo = 1;",
"newText": "const foo = 2;",
}
2. Multi (batch array)
Pass a multi array of edit objects. Each item has path, oldText, and newText. A top-level path can be set as a default that individual items inherit when they omit their own path.
{
"path": "src/utils.ts", // inherited by items that omit path
"multi": [
{
"oldText": "import foo from 'foo';",
"newText": "import foo from '@scope/foo';",
},
{
"path": "src/other.ts", // overrides the top-level path
"oldText": "const bar = 0;",
"newText": "const bar = 42;",
},
],
}
You can also mix a top-level single edit with multi — the top-level edit is prepended as the first item in the batch:
{
"path": "src/index.ts",
"oldText": "version: 1",
"newText": "version: 2",
"multi": [{ "oldText": "// old comment", "newText": "// new comment" }],
}
3. Patch (Codex-style)
Pass a patch string delimited by *** Begin Patch / *** End Patch. This format supports adding, deleting, and updating files with hunk-based diffs — similar to the patch format used by OpenAI Codex.
*** Begin Patch
*** Add File: src/new-file.ts
+export const greeting = "hello";
*** Delete File: src/deprecated.ts
*** Update File: src/existing.ts
@@ function oldName() {
-function oldName() {
+function newName() {
*** End Patch
Supported operations inside a patch:
| Header | Effect |
|---|---|
*** Add File: <path> |
Creates (or overwrites) the file with +-prefixed lines as content |
*** Delete File: <path> |
Removes the file (errors if it doesn't exist) |
*** Update File: <path> |
Applies one or more @@-delimited hunks to the file |
Note:
*** Move to:(rename) operations are not supported and will throw an error.
Codex apply_patch compatibility
The patch engine implements a pragmatic subset of the Codex apply_patch format. The following edge cases are intentionally not supported and raise a parse error instead of degrading silently:
| Feature | Status | Notes |
|---|---|---|
@@ hunk header |
Required | Every hunk inside an *** Update File: block must start with @@ |
| Trailing-whitespace tolerance | Supported | Hunks fall back to per-line trimEnd matching when exact indexOf misses |
| Full trim / unicode-normalized | Dropped | Only trimEnd is supported — normalize curly quotes or dashes in the patch before sending |
*** End of File sentinel hunks |
Dropped | Use a normal hunk anchored on the last real line |
*** Move to: rename |
Rejected | Emit an Add + Delete pair instead |
These restrictions keep the parser simpler and more predictable than a full 4-pass fuzzy matcher while still catching the most common class of whitespace mismatch.
Key Features
Preflight Validation
Before writing a single byte to disk, every edit is applied to a virtual (in-memory) snapshot of the affected files. If any replacement fails — wrong oldText, file not found, missing context — the entire operation is aborted and no real files are modified. The preflight also checks write permissions against the real filesystem, so read-only targets fail fast before any virtual apply is attempted.
Atomic Multi-File Rollback
When a classic batch spans multiple files, the applier snapshots each file's pre-edit content before writing it. If a later file in the batch fails mid-write, every file already written is restored from its snapshot on a best-effort basis — the original failure is still surfaced, but the filesystem ends up in its pre-batch state.
Positional Ordering for Same-File Edits
When multiple edits target the same file, they are automatically sorted by their position in the original file content (top-to-bottom). This ensures the forward-search cursor works correctly regardless of the order the model listed the edits.
Quote-Normalized Matching for Classic Edits
Classic oldText lookups escalate through an ordered list of normalizer passes applied to both oldText and file content. The first pass that locates the transformed string wins:
- Exact — character-for-character match
- Curly → straight quotes —
'/'/"/"in the model'soldTextare rewritten to ASCII before the second search - Trailing whitespace tolerance — per-line
trimEndon both sides catches the most frequent class of mismatch (model generates trailing spaces the file doesn't have, or vice versa)
Extending the chain is a matter of appending another normalizer to the MATCH_PASSES array in classic.ts.
Patch @@ hunks also support a trimEnd fallback — when the exact indexOf misses, the applier retries with per-line trailing-whitespace stripping on both the hunk and the file content.
Redundant Edit Detection
If the same oldText → newText pair appears more than once in a multi batch for the same file (e.g. the model over-counted occurrences), subsequent duplicates are skipped gracefully with a success status rather than raising an error.
Diff Generation
Every successful edit returns a unified diff attached to the tool result so the agent and user can inspect exactly what changed. For multi-file operations, per-file diffs are concatenated. The first changed line number is also surfaced for UI scrolling.
Path Inheritance
In multi mode, items that omit path automatically inherit the top-level path. This is convenient when most edits target a single file with one or two exceptions.
Parameters
| Parameter | Type | Description |
|---|---|---|
path |
string (optional) |
Target file path (absolute or relative to cwd). Serves as default for multi items. |
oldText |
string (optional) |
Exact text to find and replace. Must match including all whitespace. |
newText |
string (optional) |
Replacement text. |
multi |
EditItem[] (optional) |
Array of { path?, oldText, newText } objects for batch mode. |
patch |
string (optional) |
Codex-style patch payload (*** Begin Patch … *** End Patch). Mutually exclusive with all other parameters. |
EditItem shape:
{
path?: string; // inherits top-level path if omitted
oldText: string;
newText: string;
}
Dependencies
| Package | Role |
|---|---|
@mariozechner/pi-coding-agent |
ExtensionAPI type and tool registration |
@sinclair/typebox |
Runtime JSON Schema / TypeBox parameter definitions |
diff |
Line-level diff generation for result output |
Error Handling
| Situation | Behaviour |
|---|---|
patch used together with path/oldText/newText/multi |
Throws immediately — parameters are mutually exclusive |
Incomplete top-level edit (e.g. path + oldText but no newText) |
Throws listing the missing fields |
multi item missing path and no top-level path set |
Throws identifying which item is affected |
oldText not found in file |
Preflight throws; no files are modified |
patch context line not found |
Preflight throws; no files are modified |
| File does not exist or is not writable | Throws before any mutations |
Patch *** Move to: operation |
Throws — not supported |
Performance vs Base Edit
Measured across 38 real pi sessions (81 JSONL files) using npm run bench -- --from-session --all. Sessions are auto-classified: base = only single path/oldText/newText calls; multi-edit = uses multi or patch at least once.
Headline Numbers
| Metric | Base | Multi-Edit | Delta |
|---|---|---|---|
| Sessions | 22 | 16 | |
| Tool calls | 92 | 137 | |
| Logical edits | 92 | 247 | |
| Edits / tool call | 1.00 | 1.80 | +0.80 |
| Failure rate | 6.5% | 6.6% | +0.0 pp |
| P50 duration | 7 ms | 11 ms | |
| P95 duration | 31 ms | 25 ms | |
| Cost / logical edit | $0.29 | $0.17 | -41.8% |
| Calls saved vs base | — | 110 | 44.5% fewer |
What the data says
Wins:
- Cost per edit drops 42%. Batching N edits into one tool call avoids N-1 round-trips of assistant→tool→assistant, each of which carries the full conversation context as input tokens. At $0.17 vs $0.29 per logical edit, multi-edit pays for itself on any batch ≥ 2.
- 44.5% fewer tool calls. 110 hypothetical round-trips eliminated. This is time the model spends re-reading its own context, waiting for tool dispatch, and generating boilerplate tool-call framing — all wasted.
- P95 latency is lower (25 ms vs 31 ms). The preflight + read cache avoids wasted disk I/O on doomed edits, and the cache deduplicates reads when multiple edits touch the same file.
Neutral / watch items:
- P50 latency is slightly higher (11 ms vs 7 ms). Expected: multi-edit does a full preflight pass before the real write. The delta is negligible per-edit (~2 ms extra for the safety guarantee).
Mode Breakdown (pre-v1.5.1)
| Mode | Calls | % | Failure rate |
|---|---|---|---|
| single | 61 | 45% | 1.6% |
| patch | 41 | 30% | 9.8% |
| multi | 35 | 25% | 11.4% |
Root-cause analysis of the 8 multi/patch failures showed: 5 were trailing-whitespace mismatches in oldText, 2 were batch-poisoned (1 bad edit killed 5 good siblings), and 1 was a legitimate ENOENT. Three fixes shipped in v1.5.1 to address this:
trimEndmatching for classic edits —findActualStringnow tries a per-linetrimEndnormalization pass on botholdTextand file content when exact and curly-quote passes miss. Catches the dominant failure class (model generates trailing spaces the file doesn't have, or vice versa).- Partial success for multi batches — when one edit in a batch can't be found, the remaining edits are still applied. Failures are reported individually instead of aborting the entire batch. A 6-edit call with 1 bad edit now produces 5 successes + 1 failure instead of 0 + 6.
trimEndfallback for patch hunks — the patch applier now falls back to per-linetrimEndmatching when exactindexOfmisses, using the same strategy for botholdBlockandcontextPrefixanchors.
Projected impact: multi mode failure rate ~11.4% → ~3%, patch mode ~9.8% → ~2.5%. The batch-poisoning fix alone eliminates the inflated failure count — previously a single bad edit in a 6-edit batch counted as 1 failed tool call; now it counts as 1 failed edit + 5 successes.
Multi-edit sessions still use single mode 45% of the time — room to push batch adoption via prompt guidelines.
Running the Benchmark & Analysis
The benchmark-edits tool provides two modes: a synthetic benchmark that measures engine latency on controlled scenarios, and a session analysis mode that parses historical pi session JSONL logs to compute cost, token, failure, and throughput metrics.
# Synthetic benchmark — built-in scenarios
npm run bench
# Custom scenario file (JSON array — see header comment in benchmark-edits.ts)
npm run bench -- scenarios.json
# Session analysis — all pi sessions
npm run bench -- --from-session --all
# Specific session files or directories
npm run bench -- --from-session ~/.pi/agent/sessions/<project-dir>/
npm run bench -- --from-session session1.jsonl session2.jsonl