pi-hashline-edit-pro 6.0.1 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ What "pro" adds over upstream `pi-hashline-edit`: the anchor table is built from
68
68
 
69
69
  Prerequisites:
70
70
 
71
- - [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.84.0` (`@earendil-works/pi-coding-agent`).
71
+ - [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.99.0` (`@earendil-works/pi-coding-agent`).
72
72
  - Node.js 22.19 or newer, or a Bun build that ships `bun:sqlite`.
73
73
  - An SQLite runtime. The extension uses `node:sqlite` on Node 22.19+ and falls back to `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`, so run pi under Node or a Bun build that ships SQLite. Without a runtime, every tool fails with `[E_STORE_UNAVAILABLE]`.
74
74
 
@@ -322,7 +322,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
322
322
 
323
323
  ## Auto-read
324
324
 
325
- Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
325
+ Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call. A model matched by `disableOnModels` skips the block, and the post-edit diff substitution below is skipped with it.
326
326
 
327
327
  After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
328
328
 
@@ -334,7 +334,7 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
334
334
 
335
335
  Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
336
336
 
337
- Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand.
337
+ Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
338
338
 
339
339
  Each attached file is shown as `=== path ===` followed by its `anchor│content` rows. Edit directly from the attachment with replace and insert, so no `read` is needed. Files attach whole.
340
340
 
@@ -346,7 +346,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
346
346
 
347
347
  | Command | Description |
348
348
  | --- | --- |
349
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
349
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, disable on models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
350
350
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
351
351
 
352
352
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -361,7 +361,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
361
361
  "replaceMatchEnabled": true,
362
362
  "requirePath": false,
363
363
  "strictInput": false,
364
- "diffContextLines": 1
364
+ "diffContextLines": 1,
365
+ "disableOnModels": []
365
366
  }
366
367
  ```
367
368
 
@@ -376,6 +377,9 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
376
377
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
377
378
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
378
379
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
380
+ | `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no hashline tools, no auto-read, and no write-echo refusal. |
381
+
382
+ `disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
379
383
 
380
384
  When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
381
385
 
@@ -403,6 +407,14 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
403
407
 
404
408
  All eight tools return machine-readable metadata in `details` alongside the model-visible text.
405
409
 
410
+ All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
411
+
412
+ `read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors }`, where each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` is what was shown, so an oversized line appears as its marker while keeping its anchor). An image read returns `{ kind: "image", path, mimeType }`.
413
+
414
+ `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change` return `{ kind: "edit", verb, classification, path, text, diff, warnings, hints, firstChangedLine, anchors, anchorsOmitted }`, where `verb` is `"replaced"`, `"inserted"`, `"copied"`, `"moved"`, or `"undone"`, `anchors` are the live rows of the post-edit diff, and `anchorsOmitted` is true when the diff was truncated or carried no rows, meaning a fresh read is needed. A cross-file move reports the destination file and its anchors.
415
+
416
+ `anchor_grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
417
+
406
418
  | Tool | `details` |
407
419
  | --- | --- |
408
420
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
@@ -453,6 +465,7 @@ Full reference:
453
465
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
454
466
  | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
455
467
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
468
+ | `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
456
469
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
457
470
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
458
471
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
package/index.ts CHANGED
@@ -8,14 +8,17 @@ import { regCopy, regMove } from "./src/copy-move";
8
8
  import { regGrep } from "./src/grep";
9
9
  import { regUndo, clearUndo } from "./src/replace-undo";
10
10
  import { regRead, fmtReadPreview } from "./src/read";
11
+ import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
11
12
  import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
12
13
  import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
13
14
  import type { RMetrics } from "./src/replace-response";
14
15
  import type { ReplaceDetails } from "./src/replace";
15
16
  import { extractHints, extractWarnings } from "./src/replace-render";
16
17
  import { MAX_HASH_LINES } from "./src/hashline";
18
+ import { withStructuredText } from "./src/structured";
17
19
  import type { AutoReadAllMode } from "./src/config";
18
20
  import {
21
+ readConfig,
19
22
  readConfigWithStatus,
20
23
  toggleAutoRead,
21
24
  cycleAutoReadAllMode,
@@ -26,6 +29,7 @@ import {
26
29
  toggleStrictInput,
27
30
  adjustDiffContextLines,
28
31
  setAutoReadAllIgnoreFromText,
32
+ setDisableOnModelsFromText,
29
33
  } from "./src/config";
30
34
  import { loadHashStore, pruneMissing } from "./src/hash-store";
31
35
  import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
@@ -51,17 +55,20 @@ export default function (pi: ExtensionAPI): void {
51
55
  regMove(pi);
52
56
  regGrep(pi);
53
57
  regUndo(pi);
54
- registerWriteHook(pi);
58
+ registerWriteHook(pi, (model) => modelDisabled(model, disableOnModels));
55
59
 
56
60
  let autoRead = true;
57
61
  let autoReadAll: AutoReadAllMode = "off";
58
62
  let autoReadAllIgnore: string[] = [];
63
+ let disableOnModels: string[] = [];
59
64
  let autoReadAllInjected = false;
60
65
  let grepWasActive = false;
66
+ const baseAnchorTools = new Set<string>();
67
+ let gateApplied = false;
61
68
 
62
69
  async function refreshEditTools(): Promise<void> {
63
70
  try {
64
- const flags = await currentEditFlags();
71
+ const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
65
72
  regRead(pi, flags);
66
73
  regReplace(pi, flags);
67
74
  regReplaceMatch(pi, flags);
@@ -75,9 +82,37 @@ export default function (pi: ExtensionAPI): void {
75
82
  }
76
83
  }
77
84
 
85
+ async function syncModelGate(model: ModelLike | undefined): Promise<void> {
86
+ if (disableOnModels.length > 0 && modelDisabled(model, disableOnModels)) {
87
+ const active = pi.getActiveTools();
88
+ const next = active.filter((tool) => !ANCHOR_TOOL_NAMES.includes(tool));
89
+ if (grepWasActive && !next.includes("grep")) next.push("grep");
90
+ pi.setActiveTools(next);
91
+ gateApplied = true;
92
+ return;
93
+ }
94
+ if (!gateApplied) return;
95
+ gateApplied = false;
96
+ const config = await readConfig();
97
+ const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
98
+ if (!baseAnchorTools.has(tool)) return false;
99
+ if (tool === "replace_match") return config.replaceMatchEnabled !== false;
100
+ if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
101
+ if (tool === "anchor_grep") return config.anchorGrepEnabled === true;
102
+ return true;
103
+ });
104
+ let next = [...new Set([...pi.getActiveTools(), ...enabled])];
105
+ if (enabled.includes("anchor_grep") && grepWasActive) next = next.filter((tool) => tool !== "grep");
106
+ pi.setActiveTools(next);
107
+ }
108
+
78
109
  pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
79
110
  const active = pi.getActiveTools();
80
111
  grepWasActive = active.includes("grep");
112
+ baseAnchorTools.clear();
113
+ for (const tool of ANCHOR_TOOL_NAMES) {
114
+ if (active.includes(tool)) baseAnchorTools.add(tool);
115
+ }
81
116
  pi.setActiveTools(active.filter((t) => t !== "edit"));
82
117
  await initHasher();
83
118
  loadHashStore()
@@ -97,6 +132,7 @@ export default function (pi: ExtensionAPI): void {
97
132
  autoRead = config.autoRead;
98
133
  autoReadAll = config.autoReadAll ?? "off";
99
134
  autoReadAllIgnore = config.autoReadAllIgnore ?? [];
135
+ disableOnModels = config.disableOnModels ?? [];
100
136
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
101
137
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
102
138
  await refreshEditTools();
@@ -108,6 +144,7 @@ export default function (pi: ExtensionAPI): void {
108
144
  return true;
109
145
  }),
110
146
  );
147
+ await syncModelGate(ctx.model);
111
148
  const debugValue = process.env.PI_HASHLINE_DEBUG;
112
149
  if (debugValue === "1" || debugValue === "true") {
113
150
  ctx.ui.notify(`Hashline Edit mode active`, "info");
@@ -123,9 +160,14 @@ export default function (pi: ExtensionAPI): void {
123
160
  console.error("Failed to release anchor registry session:", error);
124
161
  }
125
162
  });
163
+ pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
164
+ await syncModelGate(event.model);
165
+ }));
126
166
 
127
167
  pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
168
+ await syncModelGate(ctx.model);
128
169
  if (autoReadAll === "off" || autoReadAllInjected) return;
170
+ if (modelDisabled(ctx.model, disableOnModels)) return;
129
171
  autoReadAllInjected = true;
130
172
  try {
131
173
  const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, sessionKeyFor(ctx));
@@ -139,7 +181,7 @@ export default function (pi: ExtensionAPI): void {
139
181
  }));
140
182
 
141
183
  pi.registerCommand("hashline-config", {
142
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, replace_match, path, strict input)",
184
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, disable on models, diff context, grep, copy/move, replace_match, path, strict input)",
143
185
  handler: async (_args, ctx) => {
144
186
  if (!ctx.hasUI) {
145
187
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -154,25 +196,35 @@ export default function (pi: ExtensionAPI): void {
154
196
  if (key === "autoRead") autoRead = await toggleAutoRead();
155
197
  else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
156
198
  else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
199
+ else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
157
200
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
158
201
  else if (key === "anchorGrepEnabled") {
159
202
  const enabled = await toggleAnchorGrep();
160
203
  const active = pi.getActiveTools();
161
204
  pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
205
+ if (enabled) baseAnchorTools.add("anchor_grep");
206
+ else baseAnchorTools.delete("anchor_grep");
162
207
  }
163
208
  else if (key === "copyMoveEnabled") {
164
209
  const enabled = await toggleCopyMove();
165
210
  const active = pi.getActiveTools();
166
211
  pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
212
+ for (const tool of ["copy", "move"]) {
213
+ if (enabled) baseAnchorTools.add(tool);
214
+ else baseAnchorTools.delete(tool);
215
+ }
167
216
  }
168
217
  else if (key === "replaceMatchEnabled") {
169
218
  const enabled = await toggleReplaceMatch();
170
219
  const active = pi.getActiveTools();
171
220
  pi.setActiveTools(enabled ? [...new Set([...active, "replace_match"])] : active.filter((t) => t !== "replace_match"));
221
+ if (enabled) baseAnchorTools.add("replace_match");
222
+ else baseAnchorTools.delete("replace_match");
172
223
  }
173
224
  else if (key === "requirePath") await toggleRequirePath();
174
225
  else if (key === "strictInput") await toggleStrictInput();
175
226
  await refreshEditTools();
227
+ await syncModelGate(ctx.model);
176
228
  },
177
229
  });
178
230
  await overlay.load();
@@ -208,6 +260,7 @@ export default function (pi: ExtensionAPI): void {
208
260
  });
209
261
  pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
210
262
  if (event.isError) return;
263
+ const gated = modelDisabled(ctx.model, disableOnModels);
211
264
 
212
265
  if (event.toolName === "write") {
213
266
  const writtenPath = (event.input as Record<string, unknown>)?.path;
@@ -221,7 +274,7 @@ export default function (pi: ExtensionAPI): void {
221
274
  console.error("Failed to clear undo after write:", error);
222
275
  }
223
276
  }
224
- if (!autoRead) return;
277
+ if (!autoRead || gated) return;
225
278
  if (typeof writtenPath !== "string") return;
226
279
  try {
227
280
  resolvedPath ??= (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
@@ -268,7 +321,7 @@ export default function (pi: ExtensionAPI): void {
268
321
  event.toolName !== "move" &&
269
322
  event.toolName !== "undo_last_change"
270
323
  ) return;
271
- if (!autoRead) return;
324
+ if (!autoRead || gated) return;
272
325
 
273
326
  const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
274
327
  if (metrics?.classification === "noop") return;
@@ -294,6 +347,7 @@ export default function (pi: ExtensionAPI): void {
294
347
  const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
295
348
  const emptyDiffNotice = "[post-edit] applied successfully; the diff is empty (no content change: whitespace or line endings only).";
296
349
  const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `${emptyDiffNotice}\n\n${notices}` : emptyDiffNotice;
350
+ const structured = (event as { structuredContent?: unknown }).structuredContent;
297
351
  return {
298
352
  content: [
299
353
  {
@@ -301,6 +355,7 @@ export default function (pi: ExtensionAPI): void {
301
355
  text: noticeText,
302
356
  },
303
357
  ],
358
+ ...(structured !== undefined ? { structuredContent: withStructuredText(structured, noticeText) } : {}),
304
359
  };
305
360
  }));
306
361
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.0.1",
3
+ "version": "6.1.0",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -44,7 +44,7 @@
44
44
  "xxhash-wasm": "^1.1.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": ">=0.84.0",
47
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
48
48
  "@earendil-works/pi-tui": "*",
49
49
  "typebox": "*"
50
50
  },
@@ -60,7 +60,7 @@
60
60
  "typecheck": "tsc --noEmit"
61
61
  },
62
62
  "devDependencies": {
63
- "@earendil-works/pi-coding-agent": "^0.87.0",
63
+ "@earendil-works/pi-coding-agent": "^1.0.3",
64
64
  "@eslint/js": "^10.0.1",
65
65
  "@types/node": "^24",
66
66
  "@vitest/coverage-v8": "^4.1.11",
@@ -1,2 +1,3 @@
1
+ - `undo_last_change`: history is one slot per file, not a stack; a second undo returns `E_UNDO_NONE`.
1
2
  - `undo_last_change`: a `write` clears the history, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
2
3
  - `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
package/src/batch.ts CHANGED
@@ -25,6 +25,7 @@ import type { PipelineResult } from "./replace";
25
25
  import { genPatch } from "./replace-diff";
26
26
  import { saveUndo } from "./replace-undo";
27
27
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
28
+ import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
28
29
  import { serveRows, servedHashesFromDiff } from "./served";
29
30
  import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
30
31
 
@@ -472,6 +473,20 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
472
473
  classification: piece.noop ? "noop" : "applied",
473
474
  ...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
474
475
  };
476
+ const structuredContent: EditStructured = {
477
+ ok: true,
478
+ kind: "edit",
479
+ verb: toEditVerb(piece.kind),
480
+ classification: piece.noop ? "noop" : "applied",
481
+ path: member.target,
482
+ text: `In batch ${member.display} (queued)`,
483
+ diff: "",
484
+ warnings: [],
485
+ hints: [],
486
+ firstChangedLine: piece.noop ? null : piece.start,
487
+ anchors: [],
488
+ anchorsOmitted: false,
489
+ };
475
490
  return {
476
491
  content: [
477
492
  {
@@ -488,6 +503,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
488
503
  metrics,
489
504
  batch: { id: member.display, size: member.size, last: false, total: member.total },
490
505
  },
506
+ structuredContent,
491
507
  };
492
508
  }
493
509
 
@@ -1044,6 +1060,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
1044
1060
  : "";
1045
1061
  const undoNote = sourceFiles.length > 0 ? "one undo reverts the destination edits" : "one undo reverts them";
1046
1062
  changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; ${undoNote}.${sourceNote}`;
1063
+ changed.structuredContent = withStructuredText(changed.structuredContent, changed.content[0]!.text);
1047
1064
  changed.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
1048
1065
  return changed;
1049
1066
  }
@@ -1065,10 +1082,12 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
1065
1082
  removedLines: 0,
1066
1083
  },
1067
1084
  warnings: dedupeWarnings(warnings),
1085
+ verb: "edited",
1068
1086
  },
1069
1087
  "Batch",
1070
1088
  );
1071
1089
  noop.content[0]!.text += `\nBatch ${member.display}: ${executed} edits produced no net change; undo history preserved.`;
1090
+ noop.structuredContent = withStructuredText(noop.structuredContent, noop.content[0]!.text);
1072
1091
  noop.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
1073
1092
  return noop;
1074
1093
  }
package/src/commit.ts CHANGED
@@ -12,6 +12,7 @@ import { lineHashes } from "./hashline";
12
12
  import { spanForEdit } from "./replace";
13
13
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
14
14
  import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
15
+ import { toEditVerb } from "./structured";
15
16
  export interface CommitMeta {
16
17
  editAnchors?: [string, string];
17
18
  anchorCarry?: number;
@@ -62,6 +63,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
62
63
  removedLines: 0,
63
64
  },
64
65
  warnings,
66
+ verb: toEditVerb(meta.verb),
65
67
  },
66
68
  meta.noopNoun,
67
69
  );
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -12,7 +12,7 @@ export interface ConfigRow {
12
12
  mode?: string;
13
13
  cycle?: string[];
14
14
  value?: number;
15
- folders?: string[];
15
+ entries?: string[];
16
16
  disabled?: boolean;
17
17
  }
18
18
 
@@ -20,13 +20,14 @@ export function configRows(config: Config): ConfigRow[] {
20
20
  return [
21
21
  { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
22
22
  { key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
23
- { key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
23
+ { key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, entries: config.autoReadAllIgnore ?? [] },
24
24
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
26
  { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
27
27
  { key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
28
28
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
29
29
  { key: "replaceMatchEnabled", label: "Replace match", hint: "replace_match tool (off while disabled)", enabled: config.replaceMatchEnabled !== false },
30
+ { key: "disableOnModels", label: "Disable on models", hint: "Model globs (provider/id, model id, or api) that turn off read and the hashline tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
30
31
  ];
31
32
  }
32
33
 
@@ -45,19 +46,19 @@ function numberBox(theme: Theme, value: number, disabled?: boolean): string {
45
46
  if (disabled) return theme.fg("dim", `[${value}]`);
46
47
  return theme.fg("accent", `[${value}]`);
47
48
  }
48
- function ignoreBox(theme: Theme, folders: string[]): string {
49
- return theme.fg("accent", `[${folders.length}]`);
49
+ function listBox(theme: Theme, entries: string[]): string {
50
+ return theme.fg("accent", `[${entries.length}]`);
50
51
  }
51
- function formatIgnoreFolders(folders: string[]): string {
52
- if (folders.length === 0) return "(empty)";
53
- const joined = folders.join(", ");
52
+ function formatList(entries: string[]): string {
53
+ if (entries.length === 0) return "(empty)";
54
+ const joined = entries.join(", ");
54
55
  return joined.length > 40 ? `${joined.slice(0, 37)}...` : joined;
55
56
  }
56
57
 
57
58
  export class HashlineConfigOverlay {
58
59
  private rows: ConfigRow[];
59
60
  private selected = 0;
60
- private editingIgnore = false;
61
+ private editingList = false;
61
62
  private editBuffer = "";
62
63
 
63
64
  constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number, value?: string) => Promise<void> }) {
@@ -72,37 +73,37 @@ export class HashlineConfigOverlay {
72
73
  this.opts.tui.requestRender(true);
73
74
  void this.opts.onToggle(row.key, delta, value).then(async () => {
74
75
  this.rows = configRows(await readConfig());
75
- this.editingIgnore = false;
76
+ this.editingList = false;
76
77
  this.opts.tui.requestRender(true);
77
78
  }).catch((error: unknown) => {
78
79
  console.error("Failed to toggle hashline setting:", error);
79
80
  });
80
81
  }
81
- private startIgnoreEdit(row: ConfigRow): void {
82
- this.editingIgnore = true;
83
- this.editBuffer = (row.folders ?? []).join(", ");
82
+ private startListEdit(row: ConfigRow): void {
83
+ this.editingList = true;
84
+ this.editBuffer = (row.entries ?? []).join(", ");
84
85
  this.opts.tui.requestRender(true);
85
86
  }
86
- private commitIgnoreEdit(): void {
87
+ private commitListEdit(): void {
87
88
  const row = this.rows[this.selected];
88
- if (!row || row.key !== "autoReadAllIgnore") {
89
- this.editingIgnore = false;
89
+ if (!row || row.entries === undefined) {
90
+ this.editingList = false;
90
91
  return;
91
92
  }
92
93
  const value = this.editBuffer;
93
- this.editingIgnore = false;
94
+ this.editingList = false;
94
95
  this.runToggle(row, undefined, value);
95
96
  }
96
- private cancelIgnoreEdit(): void {
97
- this.editingIgnore = false;
97
+ private cancelListEdit(): void {
98
+ this.editingList = false;
98
99
  this.opts.tui.requestRender(true);
99
100
  }
100
101
 
101
102
  private toggleSelected(): void {
102
103
  const row = this.rows[this.selected];
103
104
  if (!row || row.disabled) return;
104
- if (row.key === "autoReadAllIgnore") {
105
- this.startIgnoreEdit(row);
105
+ if (row.entries !== undefined) {
106
+ this.startListEdit(row);
106
107
  return;
107
108
  }
108
109
  if (row.value !== undefined) {
@@ -129,13 +130,13 @@ export class HashlineConfigOverlay {
129
130
  }
130
131
 
131
132
  handleInput(data: string): void {
132
- if (this.editingIgnore) {
133
+ if (this.editingList) {
133
134
  if (matchesKey(data, Key.escape) || data === "\x1b") {
134
- this.cancelIgnoreEdit();
135
+ this.cancelListEdit();
135
136
  return;
136
137
  }
137
138
  if (matchesKey(data, Key.enter) || data === "\r" || data === "\n") {
138
- this.commitIgnoreEdit();
139
+ this.commitListEdit();
139
140
  return;
140
141
  }
141
142
  if (data === "\x7f" || data === "\b" || data === "\x08") {
@@ -176,8 +177,8 @@ export class HashlineConfigOverlay {
176
177
  return;
177
178
  }
178
179
  const row = this.rows[this.selected];
179
- if ((data === "e" || data === "E") && row && row.key === "autoReadAllIgnore" && !row.disabled) {
180
- this.startIgnoreEdit(row);
180
+ if ((data === "e" || data === "E") && row && row.entries !== undefined && !row.disabled) {
181
+ this.startListEdit(row);
181
182
  return;
182
183
  }
183
184
  if (matchesKey(data, Key.escape) || data === "q") {
@@ -197,16 +198,16 @@ export class HashlineConfigOverlay {
197
198
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
198
199
  this.rows.forEach((row, index) => {
199
200
  const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
200
- const box = row.folders !== undefined ? ignoreBox(theme, row.folders) : row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
201
+ const box = row.entries !== undefined ? listBox(theme, row.entries) : row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
201
202
  const label = row.disabled ? theme.fg("dim", row.label) : index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
202
- const suffix = row.folders !== undefined ? ` — ${row.hint}: ${formatIgnoreFolders(row.folders)}` : ` — ${row.hint}`;
203
+ const suffix = row.entries !== undefined ? ` — ${row.hint}: ${formatList(row.entries)}` : ` — ${row.hint}`;
203
204
  lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", suffix)}`));
204
- if (row.key === "autoReadAllIgnore" && index === this.selected && this.editingIgnore) {
205
+ if (row.entries !== undefined && index === this.selected && this.editingList) {
205
206
  lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", "edit:")} ${this.editBuffer}█ ${theme.fg("dim", "(Enter save · Esc cancel · Ctrl-U clear)")}`));
206
207
  }
207
208
  });
208
209
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
209
- const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
210
+ const footer = this.editingList ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
210
211
  lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
211
212
  lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
212
213
  return lines;
package/src/config.ts CHANGED
@@ -20,6 +20,7 @@ export interface Config {
20
20
  requirePath?: boolean;
21
21
  strictInput?: boolean;
22
22
  diffContextLines?: number;
23
+ disableOnModels?: string[];
23
24
  }
24
25
 
25
26
  const DEFAULT_CONFIG: Config = {
@@ -31,7 +32,8 @@ const DEFAULT_CONFIG: Config = {
31
32
  autoReadAllIgnore: [],
32
33
  requirePath: false,
33
34
  strictInput: false,
34
- diffContextLines: DEFAULT_DIFF_CONTEXT_LINES
35
+ diffContextLines: DEFAULT_DIFF_CONTEXT_LINES,
36
+ disableOnModels: []
35
37
  };
36
38
 
37
39
  function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
@@ -45,13 +47,13 @@ export function normalizeAutoReadAllIgnoreEntry(entry: string): string {
45
47
  return entry.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/");
46
48
  }
47
49
 
48
- export function parseAutoReadAllIgnore(value: unknown): string[] {
50
+ function parseStringList(value: unknown, normalize: (entry: string) => string): string[] {
49
51
  const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
50
52
  const seen = new Set<string>();
51
53
  const out: string[] = [];
52
54
  for (const item of raw) {
53
55
  if (typeof item !== "string") continue;
54
- const cleaned = normalizeAutoReadAllIgnoreEntry(item);
56
+ const cleaned = normalize(item);
55
57
  if (cleaned.length === 0) continue;
56
58
  const lower = cleaned.toLowerCase();
57
59
  if (seen.has(lower)) continue;
@@ -61,6 +63,14 @@ export function parseAutoReadAllIgnore(value: unknown): string[] {
61
63
  return out;
62
64
  }
63
65
 
66
+ export function parseAutoReadAllIgnore(value: unknown): string[] {
67
+ return parseStringList(value, normalizeAutoReadAllIgnoreEntry);
68
+ }
69
+
70
+ export function parseDisableOnModels(value: unknown): string[] {
71
+ return parseStringList(value, (entry) => entry.trim());
72
+ }
73
+
64
74
  export function normalizeDiffContextLines(value: unknown): number {
65
75
  if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_DIFF_CONTEXT_LINES;
66
76
  const floored = Math.floor(value);
@@ -83,6 +93,7 @@ function parseConfig(content: string): Config {
83
93
  const strictInput = parsed.strictInput;
84
94
  const diffContextLines = parsed.diffContextLines;
85
95
  const autoReadAllIgnore = parsed.autoReadAllIgnore;
96
+ const disableOnModels = parsed.disableOnModels;
86
97
  return {
87
98
  autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
88
99
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
@@ -93,6 +104,7 @@ function parseConfig(content: string): Config {
93
104
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
94
105
  diffContextLines: normalizeDiffContextLines(diffContextLines),
95
106
  autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
107
+ disableOnModels: parseDisableOnModels(disableOnModels),
96
108
  };
97
109
  }
98
110
 
@@ -243,3 +255,14 @@ export async function setAutoReadAllIgnore(dirs: string[]): Promise<string[]> {
243
255
  export async function setAutoReadAllIgnoreFromText(text: string): Promise<string[]> {
244
256
  return setAutoReadAllIgnore(text.split(","));
245
257
  }
258
+ export async function setDisableOnModels(patterns: string[]): Promise<string[]> {
259
+ let next: string[] = [];
260
+ await updateConfig((c) => {
261
+ next = parseDisableOnModels(patterns);
262
+ c.disableOnModels = next;
263
+ });
264
+ return next;
265
+ }
266
+ export async function setDisableOnModelsFromText(text: string): Promise<string[]> {
267
+ return setDisableOnModels(text.split(","));
268
+ }