decorated-pi 0.2.1 → 0.3.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
@@ -1,82 +1,76 @@
1
1
  # decorated-pi
2
2
 
3
- `decorated-pi` is a Pi extension that adds safety gates, LSP tools, image/compaction model helpers, smarter `@` file search, dynamic subdirectory `AGENTS.md` loading, and a few workflow quality-of-life improvements.
3
+ `decorated-pi` is a Pi extension that adds stronger edit tool, safety gates, LSP tools, image/compaction model helpers, smarter `@` file search, dynamic subdirectory `AGENTS.md` loading, and a few workflow quality-of-life improvements.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:decorated-pi
9
+ pi install git:github.com/lcwecker/decorated-pi
10
+ pi install /path/to/decorated-pi
11
+ ```
4
12
 
5
13
  ## Features
6
14
 
7
- ### 1. Safety Layer
15
+ ### 1. Patch Tool
8
16
 
9
- - **Dangerous bash guard**
10
- - asks for confirmation on destructive commands such as:
11
- - `rm`
12
- - `sudo`
13
- - `svn commit/revert`
14
- - `git reset/restore/clean/push/revert`
15
- - `npm publish`
16
- - `>` / `1>` / `2>` / `&>` / `tee` overwrite existing files
17
- - Hints the agent to use `edit` instead of `write` on non-empty files
18
- - **Protected paths**
19
- - Blocks read/write access (via `write`/`edit`/`read` tools or `cat`/`head`/`tail`/`grep`/`rg` etc. bash commands) to sensitive locations such as `.env`, `.git/`, `.ssh/`, `*.pem`, `*.key`, etc.
20
- - **Secret redaction**
21
- - Dual-layer detection: 40+ known-format patterns (AWS, GitHub, OpenAI, etc.) + Adjusted Shannon Entropy analysis for unknown formats. Based on [opencode-secrets-protect](https://github.com/jscheel/opencode-secrets-protect) (MIT)
17
+ Replaces Pi's built-in `edit` / `write` with a stronger `patch` tool:
18
+
19
+ - **anchor mechanism** — narrows the search range by specifying a unique string that appears before `old_str`, preventing mismatches in files with repeated patterns
20
+ - **mtime tracking** — records file modification time on `read`, rejects `patch` if the file changed since last read, preventing blind or stale edits
21
+ - **explicit overwrite** — offer atomic `overwrite: true` mode for overwrite files or full-file creation to prevent unintened overwrite
22
22
 
23
23
  ### 2. Smart `@` File Search
24
24
 
25
- Replaces Pi's default file search with a faster project-aware search strategy:
25
+ Replaces Pi's default file search with a faster, project-aware search strategy:
26
26
 
27
- - Uses `git ls-files` in git repos
28
- - Falls back to `fd` outside git repos
29
- - Caches results for 10 seconds
30
- - Scores primarily on **filename match quality**, not full-path fuzziness
31
- - Penalizes hidden/cache/build directories
32
- - Hides hidden paths from empty-query results
27
+ - Uses project-aware file discovery
28
+ - Prioritizes filename-based matches for more intuitive results
29
+ - Reduces clutter from hidden, cache, and build directories
30
+ - Keeps default suggestions focused on visible project files
33
31
 
34
32
  ### 3. LSP Tool Suite
35
33
 
36
- Based on [@spences10/pi-lsp](https://github.com/spences10/my-pi/tree/main/packages/pi-lsp) by Scott Spence (MIT License), with additions:
34
+ Based on [@spences10/pi-lsp](https://github.com/spences10/my-pi/tree/main/packages/pi-lsp), with major additions:
37
35
 
38
- - C/C++ (clangd) and Lua support
36
+ - C/C++ and Lua support
39
37
  - `lsp_find_symbol`, `lsp_rename`, multi-file support merged into `lsp_diagnostics`
40
38
  - Force-sync on `didChange` (no stale diagnostics)
41
39
 
42
- Registered tools:
43
-
44
- - `lsp_diagnostics`
45
- - `lsp_find_symbol`
46
- - `lsp_hover`
47
- - `lsp_definition`
48
- - `lsp_references`
49
- - `lsp_document_symbols`
50
- - `lsp_rename`
51
-
52
- Supported languages:
53
-
54
- - c/cpp
55
- - go
56
- - java
57
- - lua
58
- - python
59
- - ruby
60
- - rust
61
- - svelte
62
- - typescript
40
+ Supported languages: c/cpp, go, java, lua, python, ruby, rust, svelte, typescript
63
41
 
64
42
  ### 4. Auxiliary Models (Image + Compact)
65
43
 
66
- Uses cheaper models for auxiliary tasks, configured via `/extend-model`:
44
+ Uses cheaper models for auxiliary tasks, configured via `/dp-model`:
67
45
 
68
46
  - **Image read fallback** — when the model reads an image file, detects type via magic bytes, calls a configured vision-capable model, and replaces the read result with image analysis text (jpeg, png, gif, webp)
69
47
  - **Compact model** — uses a configured model for context compaction (instead of the main model), auto-resumes after compaction.
70
48
 
71
- ### 5. Dynamic Subdirectory `AGENTS.md` / `CLAUDE.md`
49
+ ### 5. Safety Layer
50
+
51
+ - **Dangerous bash guard**
52
+ - asks for confirmation on destructive commands such as:
53
+ - `rm`
54
+ - `sudo`
55
+ - `svn commit/revert`
56
+ - `git reset/restore/clean/push/revert`
57
+ - `npm publish`
58
+ - `>` / `1>` / `2>` / `&>` / `tee` overwrite existing files
59
+ - Hints the agent to use `edit` instead of `write` on non-empty files
60
+ - **Protected paths**
61
+ - Blocks read/write access (via `write`/`edit`/`read` tools or `cat`/`head`/`tail`/`grep`/`rg` etc. bash commands) to sensitive locations such as `.env`, `.git/`, `.ssh/`, `*.pem`, `*.key`, etc.
62
+ - **Secret redaction**
63
+ - Three-layer detection: high-confidence known-format patterns (AWS, GitHub, OpenAI, etc.), config-key regex matching, and adjusted Shannon entropy heuristics for unknown secret-like values.
64
+ - Based on [opencode-secrets-protect](https://github.com/jscheel/opencode-secrets-protect)
65
+
66
+ ### 6. Dynamic Subdirectory `AGENTS.md` / `CLAUDE.md`
72
67
 
73
68
  When the agent reads or edits a file:
74
69
 
75
70
  - discovers `AGENTS.md` / `CLAUDE.md` in the file's directory and ancestor directories
76
71
  - injects newly discovered guidance into tool results
77
- - persists discovered files into the session so they are restored on resume
78
72
 
79
- ### 6. Extend Providers
73
+ ### 7. Extend Providers
80
74
 
81
75
  Extend providers are registered via `/login` → "Use a subscription":
82
76
 
@@ -86,16 +80,6 @@ Extend providers are registered via `/login` → "Use a subscription":
86
80
  | Baidu Qianfan | `qianfan.baidubce.com/v2/coding` |
87
81
  | ARK Coding | `ark.cn-beijing.volces.com/api/coding/v3` |
88
82
 
89
- ## Install
90
-
91
- ```bash
92
- pi install /path/to/decorated-pi #local
93
- pi install npm:decorated-pi #npm
94
- pi install git:github.com/lcwecker/decorated-pi #github
95
- ```
96
-
97
- Then reload Pi
98
-
99
83
  ## Configuration
100
84
 
101
85
  Runtime settings are stored in:
@@ -104,6 +88,30 @@ Runtime settings are stored in:
104
88
  ~/.pi/agent/decorated-pi.json
105
89
  ```
106
90
 
91
+ ### Module Loading
92
+
93
+ Modules can be toggled on/off. Changes take effect after `/reload`.
94
+
95
+ | Module | Default | Effect when disabled |
96
+ | -------- | --------- | --------------------- |
97
+ | `patch` | `true` | Reverts to Pi's built-in `edit` / `write` tools |
98
+ | `safety` | `true` | No command guard, no protected path check, no secret redaction |
99
+ | `lsp` | `true` | All `lsp_*` tools unregistered — no diagnostics, hover, etc. |
100
+ | `smart-at` | `true` | Fallback to Pi's built-in `@` file completion |
101
+
102
+ Use `/dp-settings` to toggle, or edit the config file directly:
103
+
104
+ ```json
105
+ {
106
+ "modules": {
107
+ "patch": true,
108
+ "safety": true,
109
+ "lsp": false,
110
+ "smart-at": true
111
+ }
112
+ }
113
+ ```
114
+
107
115
  ## License
108
116
 
109
117
  MIT
@@ -0,0 +1,66 @@
1
+ /**
2
+ * File mtime tracking for stale-read protection.
3
+ *
4
+ * - `read` tool records mtime when the LLM reads a file
5
+ * - `patch` tool checks mtime before editing — rejects if file changed since last read
6
+ * - `patch` tool updates mtime after a successful write
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ /** Last-known mtime for each absolute file path (ms since epoch). */
13
+ const readMarkers = new Map<string, number>();
14
+
15
+ /** Get current file mtime in ms. Throws if file doesn't exist. */
16
+ function getFileMtime(absPath: string): number {
17
+ const stat = fs.statSync(absPath);
18
+ return stat.mtimeMs;
19
+ }
20
+
21
+ /** Record that the LLM has seen the current version of a file.
22
+ * Called after `read` tool completes and after `patch` writes. */
23
+ export function recordReadTime(absPath: string): void {
24
+ if (!fs.existsSync(absPath)) return;
25
+ readMarkers.set(absPath, getFileMtime(absPath));
26
+ }
27
+
28
+ /** Check if file has been modified since last read.
29
+ * Returns an error message if stale, or undefined if ok to edit. */
30
+ export function checkStaleFile(absPath: string, displayPath: string): string | undefined {
31
+ // If file doesn't exist on disk, always allow — creating a new file
32
+ // doesn't require reading it first, and recreating a deleted file is safe.
33
+ if (!fs.existsSync(absPath)) {
34
+ return undefined;
35
+ }
36
+
37
+ const lastRead = readMarkers.get(absPath);
38
+ if (lastRead === undefined) {
39
+ // File exists but never read — must read first to avoid blind edits
40
+ return (
41
+ `File not read yet: ${displayPath}. ` +
42
+ `Please read the file with the read tool before editing.`
43
+ );
44
+ }
45
+
46
+ const currentMtime = getFileMtime(absPath);
47
+ if (currentMtime > lastRead) {
48
+ return (
49
+ `File modified since last read: ${displayPath}. ` +
50
+ `Please re-read the file with the read tool before editing.`
51
+ );
52
+ }
53
+
54
+ return undefined;
55
+ }
56
+
57
+ /** Clear all tracked file times (e.g., on session start). */
58
+ export function clearReadMarkers(): void {
59
+ readMarkers.clear();
60
+ }
61
+
62
+ /** Resolve a relative path to absolute (for consistent map keys). */
63
+ export function resolveAbsolutePath(cwd: string, filePath: string): string {
64
+ if (path.isAbsolute(filePath)) return path.normalize(filePath);
65
+ return path.normalize(path.resolve(cwd, filePath));
66
+ }
@@ -3,24 +3,30 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { setupSafety } from "./safety";
7
- import { setupExtendModel } from "./extend-model";
6
+ import { setupSafety } from "./safety/index.js";
7
+ import { setupModelIntegration } from "./model-integration";
8
8
  import { setupSlash } from "./slash";
9
9
  import { setupSubdirAgents } from "./subdir-agents";
10
10
  import { setupSessionTitle } from "./session-title";
11
+ import { setupIO } from "./io";
11
12
  import { setupGuidance } from "./guidance";
12
13
  import { setupLsp } from "./lsp/index";
13
14
  import { setupProviders } from "./providers/index";
14
15
  import { setupSmartAt } from "./smart-at";
16
+ import { isModuleEnabled } from "./settings";
15
17
 
16
18
  export default function (pi: ExtensionAPI) {
17
- setupSafety(pi);
18
- setupExtendModel(pi);
19
+ // Always loaded — core commands and providers
19
20
  setupSlash(pi);
21
+ setupProviders(pi);
22
+ setupModelIntegration(pi);
20
23
  setupSubdirAgents(pi);
21
24
  setupSessionTitle(pi);
22
25
  setupGuidance(pi);
23
- setupLsp(pi);
24
- setupProviders(pi);
25
- setupSmartAt(pi);
26
+
27
+ // Configurable modules
28
+ if (isModuleEnabled("patch")) setupIO(pi);
29
+ if (isModuleEnabled("safety")) setupSafety(pi);
30
+ if (isModuleEnabled("lsp")) setupLsp(pi);
31
+ if (isModuleEnabled("smart-at")) setupSmartAt(pi);
26
32
  }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * IO — Replace Pi native edit/write with `patch` tool (single-file)
3
+ *
4
+ * Keeps: read (for stale-read protection via mtime tracking)
5
+ * Removes: edit, write
6
+ * Adds: patch (old_str/new_str exact replacement, single file per call)
7
+ *
8
+ * Schema: { path, edits?, overwrite?, new_str? }
9
+ * Pi’s native parallel tool calls handle multi-file scenarios.
10
+ *
11
+ * Stale-read protection:
12
+ * - `read` tool records file mtime when LLM reads a file
13
+ * - `patch` tool checks: if file mtime > last-read mtime → reject
14
+ * - `patch` tool updates mtime after successful write
15
+ *
16
+ * TUI Rendering Pitfalls (learned the hard way):
17
+ * 1. execute() MUST throw errors, NOT return { isError: true }
18
+ * 2. TUI rendering MUST mirror the edit tool pattern exactly
19
+ * 3. getPatchHeaderBg: settledError MUST be checked first
20
+ * 4. renderResult must NOT return the Box
21
+ * 5. Error text must go INSIDE the Box, not in the result Container
22
+ * 6. prepareArguments must handle literal newlines in JSON strings
23
+ */
24
+
25
+ import { defineTool, isReadToolResult, keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { renderDiff } from "@earendil-works/pi-coding-agent";
27
+ import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
28
+ import { Type } from "typebox";
29
+ import {
30
+ applyPatch,
31
+ formatPatchResult,
32
+ generatePatchDiff,
33
+ computePatchPreview,
34
+ type PatchPreview,
35
+ } from "./patch.js";
36
+ import { recordReadTime, checkStaleFile, clearReadMarkers, resolveAbsolutePath } from "./file-times.js";
37
+
38
+ // ─── Schema ─────────────────────────────────────────────────────────────────────────
39
+
40
+ const EditSchema = Type.Object({
41
+ anchor: Type.Optional(Type.String({
42
+ description: "Optional unique string that appears BEFORE old_str in the file. Narrows the search range.",
43
+ })),
44
+ old_str: Type.String({
45
+ description: "Exact text to find. Must be unique within the search range. String, not regex.",
46
+ }),
47
+ new_str: Type.String({
48
+ description: "Replacement text. String. Use empty string to delete.",
49
+ }),
50
+ });
51
+
52
+ const PatchSchema = Type.Object({
53
+ path: Type.String({
54
+ description: "Path to the file to edit (relative or absolute).",
55
+ }),
56
+ edits: Type.Optional(Type.Array(EditSchema, {
57
+ description: "Targeted replacements applied sequentially.",
58
+ })),
59
+ overwrite: Type.Optional(Type.Boolean({
60
+ description: "If true, replace the entire file atomically (write temp → mv).",
61
+ })),
62
+ new_str: Type.Optional(Type.String({
63
+ description: "Entire new file content when overwrite is true.",
64
+ })),
65
+ });
66
+
67
+ // ─── Argument repair ───────────────────────────────────────────────────────────────
68
+
69
+ function fixJsonNewlines(str: string): string {
70
+ let result = '';
71
+ let inString = false;
72
+ let escaped = false;
73
+ for (let i = 0; i < str.length; i++) {
74
+ const ch = str[i];
75
+ if (escaped) { result += ch; escaped = false; continue; }
76
+ if (ch === '\\') { result += ch; escaped = true; continue; }
77
+ if (ch === '"') { inString = !inString; result += ch; continue; }
78
+ if (inString && (ch === '\n' || ch === '\r')) {
79
+ result += ch === '\n' ? '\\n' : '\\r';
80
+ continue;
81
+ }
82
+ result += ch;
83
+ }
84
+ return result;
85
+ }
86
+
87
+ function jsonParseWithNewlineFix(str: string): any {
88
+ try { return JSON.parse(str); }
89
+ catch { try { return JSON.parse(fixJsonNewlines(str)); } catch { return undefined; } }
90
+ }
91
+
92
+ export function preparePatchArguments(input: any): any {
93
+ if (!input || typeof input !== "object") return input;
94
+
95
+ const args = input as Record<string, any>;
96
+
97
+ // Legacy multi-file format: { patches: [{ path, edits }] } → extract first
98
+ if (Array.isArray(args.patches) && !args.path) {
99
+ const first = args.patches[0];
100
+ if (first && typeof first === "object" && first.path) {
101
+ Object.assign(args, first);
102
+ delete args.patches;
103
+ }
104
+ } else if (typeof args.patches === "string" && !args.path) {
105
+ try {
106
+ const parsed = jsonParseWithNewlineFix(args.patches);
107
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed[0]?.path) {
108
+ Object.assign(args, parsed[0]);
109
+ delete args.patches;
110
+ } else if (parsed && typeof parsed === "object" && parsed.path) {
111
+ Object.assign(args, parsed);
112
+ delete args.patches;
113
+ }
114
+ } catch { /* keep original */ }
115
+ }
116
+
117
+ // Edits serialized as JSON string
118
+ if (typeof args.edits === "string") {
119
+ try {
120
+ const parsed = jsonParseWithNewlineFix(args.edits);
121
+ if (Array.isArray(parsed)) args.edits = parsed;
122
+ } catch { /* keep original */ }
123
+ }
124
+
125
+ // Legacy: top-level old_str/new_str instead of edits array
126
+ if (typeof args.old_str === "string" && typeof args.new_str === "string") {
127
+ const edit: any = { old_str: args.old_str, new_str: args.new_str };
128
+ if (typeof args.anchor === "string") edit.anchor = args.anchor;
129
+ args.edits = args.edits ? [...args.edits, edit] : [edit];
130
+ delete args.old_str;
131
+ delete args.new_str;
132
+ delete args.anchor;
133
+ }
134
+
135
+ return args;
136
+ }
137
+
138
+ // ─── TUI rendering ─────────────────────────────────────────────────────────────────
139
+
140
+ interface PatchCallComponent extends Box {
141
+ preview?: PatchPreview;
142
+ previewArgsKey?: string;
143
+ previewPending?: boolean;
144
+ settledError: boolean;
145
+ }
146
+
147
+ function createPatchCallComponent(): PatchCallComponent {
148
+ return Object.assign(new Box(1, 1, (text: string) => text), {
149
+ preview: undefined,
150
+ previewArgsKey: undefined,
151
+ previewPending: false,
152
+ settledError: false,
153
+ });
154
+ }
155
+
156
+ function getPatchCallComponent(state: any, lastComponent: any): PatchCallComponent {
157
+ if (lastComponent instanceof Box) {
158
+ const comp = lastComponent as PatchCallComponent;
159
+ state.callComponent = comp;
160
+ return comp;
161
+ }
162
+ if (state.callComponent) return state.callComponent;
163
+ const comp = createPatchCallComponent();
164
+ state.callComponent = comp;
165
+ return comp;
166
+ }
167
+
168
+ function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
169
+ if (component.settledError) {
170
+ return (text: string) => theme.bg("toolErrorBg", text);
171
+ }
172
+ if (component.preview) {
173
+ if ("error" in component.preview && component.preview.error) {
174
+ return (text: string) => theme.bg("toolErrorBg", text);
175
+ }
176
+ return (text: string) => theme.bg("toolSuccessBg", text);
177
+ }
178
+ return (text: string) => theme.bg("toolPendingBg", text);
179
+ }
180
+
181
+ function createSingleLineComponent(text: string) {
182
+ return {
183
+ render(width: number) {
184
+ return [truncateToWidth(text, width)];
185
+ },
186
+ invalidate() {},
187
+ };
188
+ }
189
+
190
+ function appendPatchDiffChildren(parent: Box, body: string, theme: any): void {
191
+ const rawLines = body.split("\n");
192
+ let buffer: string[] = [];
193
+
194
+ const flush = () => {
195
+ if (buffer.length === 0) return;
196
+ parent.addChild(new Text(renderDiff(buffer.join("\n")), 0, 0));
197
+ buffer = [];
198
+ };
199
+
200
+ for (const line of rawLines) {
201
+ if (line.startsWith("@@ lines ")) {
202
+ flush();
203
+ parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
204
+ continue;
205
+ }
206
+ if (line === "anchors:") {
207
+ flush();
208
+ parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
209
+ continue;
210
+ }
211
+ if (line.startsWith(" - ")) {
212
+ flush();
213
+ parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
214
+ continue;
215
+ }
216
+ buffer.push(line);
217
+ }
218
+
219
+ flush();
220
+ }
221
+
222
+ function buildPatchCallComponent(component: PatchCallComponent, args: any, theme: any, expanded = false) {
223
+ component.setBgFn(getPatchHeaderBg(component, theme));
224
+ component.clear();
225
+
226
+ let label = "";
227
+ if (args?.path) {
228
+ label = theme.fg("accent", args.path);
229
+ if (args.overwrite) {
230
+ label += theme.fg("warning", " [overwrite]");
231
+ } else if (args.edits?.length > 0) {
232
+ label += theme.fg("dim", ` (${args.edits.length} edit${args.edits.length > 1 ? "s" : ""})`);
233
+ }
234
+ }
235
+ const headerText = theme.fg("toolTitle", theme.bold("patch")) + (label ? " " + label : "");
236
+ component.addChild(new Text(headerText, 0, 0));
237
+
238
+ if (component.settledError || !component.preview) return component;
239
+
240
+ const preview = component.preview;
241
+ let body = "";
242
+ if ("isOverwrite" in preview && preview.isOverwrite && preview.preview) {
243
+ body = preview.preview;
244
+ } else if ("diff" in preview && preview.diff) {
245
+ body = preview.diff;
246
+ } else if ("error" in preview && preview.error) {
247
+ component.addChild(new Spacer(1));
248
+ component.addChild(new Text(theme.fg("error", ` Error: ${preview.error}`), 0, 0));
249
+ return component;
250
+ }
251
+
252
+ if (!body) return component;
253
+
254
+ const lines = body.split("\n");
255
+ const FOLD_THRESHOLD = 45;
256
+
257
+ component.addChild(new Spacer(1));
258
+ if (lines.length > FOLD_THRESHOLD && !expanded) {
259
+ // 折叠态: 显示前几行 + 摘要
260
+ const shown = lines.slice(0, 10).join("\n");
261
+ const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
262
+ if (isOverwrite) {
263
+ component.addChild(new Text(theme.fg("toolDiffAdded", shown), 0, 0));
264
+ } else {
265
+ appendPatchDiffChildren(component, shown, theme);
266
+ }
267
+ component.addChild(new Text(
268
+ theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + keyHint("app.tools.expand", "expand") + theme.fg("dim", ")"),
269
+ 0, 0,
270
+ ));
271
+ } else {
272
+ // 展开态: 完整显示
273
+ const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
274
+ if (isOverwrite) {
275
+ component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
276
+ } else {
277
+ appendPatchDiffChildren(component, body, theme);
278
+ }
279
+ }
280
+
281
+ return component;
282
+ }
283
+
284
+ // ─── Setup ──────────────────────────────────────────────────────────────────────────
285
+
286
+ export function setupIO(pi: ExtensionAPI) {
287
+ pi.on("session_start", () => {
288
+ clearReadMarkers();
289
+ const active = pi.getActiveTools();
290
+ pi.setActiveTools(active.filter(t => !["edit", "write"].includes(t)));
291
+ });
292
+
293
+ // Track file read times
294
+ pi.on("tool_result", (event, ctx) => {
295
+ if (!isReadToolResult(event)) return;
296
+ const filePath = event.input?.path;
297
+ if (typeof filePath !== "string" || !filePath.trim()) return;
298
+ const cwd: string = ctx.cwd ?? process.cwd();
299
+ const absPath = resolveAbsolutePath(cwd, filePath);
300
+ recordReadTime(absPath);
301
+ });
302
+
303
+ pi.registerTool(defineTool({
304
+ name: "patch",
305
+ label: "Patch",
306
+ description: [
307
+ "Edits a file using exact string replacement, with optional anchor to narrow search.",
308
+ "When old_str is not unique, add more surrounding context or use anchor to narrow search.",
309
+ "If possible, prefer anchor for more robust edits. Anchor must be a unique string that appears BEFORE old_str in the file.",
310
+ "",
311
+ "Examples:",
312
+ ' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return y" }] }',
313
+ ' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
314
+ ' { path: "src/bar.ts", overwrite: true, new_str: "entire file content" }',
315
+ "Anchor examples (provide enough context to make it unique):",
316
+ ' prefer "function foo()" over "foo".',
317
+ ' prefer "class Foo" over "Foo".',
318
+ ' prefer "## Section Title" in md file edits.',
319
+ ].join("\n"),
320
+ promptSnippet: "Edits a file using exact string replacement, with anchor support and overwrite mode.",
321
+ promptGuidelines: [
322
+ "Always prefer modifying files with PATCH tool over bash commands or python scripts.",
323
+ "For full-file replacement, always use patch tool to prevent unintended edits or data loss.",
324
+ ],
325
+ parameters: PatchSchema,
326
+ renderShell: "self",
327
+ prepareArguments: preparePatchArguments,
328
+ execute: async (_toolCallId: string, input: { path: string; edits?: any[]; overwrite?: boolean; new_str?: string }, _signal: any, _onUpdate: any, ctx: any) => {
329
+ const cwd: string = ctx.cwd ?? process.cwd();
330
+
331
+ // Stale-read protection (only for edits, not overwrite)
332
+ if (!input.overwrite && input.path?.trim()) {
333
+ const absPath = resolveAbsolutePath(cwd, input.path);
334
+ const staleError = checkStaleFile(absPath, input.path);
335
+ if (staleError) throw new Error(staleError);
336
+ }
337
+
338
+ const result = await applyPatch(input as any, cwd);
339
+
340
+ // Update read markers after successful write
341
+ for (const filePath of [...result.modified, ...result.created]) {
342
+ const absPath = resolveAbsolutePath(cwd, filePath);
343
+ recordReadTime(absPath);
344
+ }
345
+
346
+ const summary = formatPatchResult(result);
347
+ const diff = generatePatchDiff(result);
348
+ return {
349
+ content: [{ type: "text", text: summary }],
350
+ details: { diff },
351
+ };
352
+ },
353
+
354
+ renderCall(args: any, theme: any, context: any) {
355
+ const state = context.state;
356
+ const component = getPatchCallComponent(state, context.lastComponent);
357
+
358
+ const argsKey = args ? JSON.stringify(args) : undefined;
359
+ if (component.previewArgsKey !== argsKey) {
360
+ component.preview = undefined;
361
+ component.previewArgsKey = argsKey;
362
+ component.previewPending = false;
363
+ component.settledError = false;
364
+ }
365
+
366
+ if (context.argsComplete && !component.preview && !component.previewPending) {
367
+ component.previewPending = true;
368
+ void computePatchPreview(args, context.cwd).then((preview: any) => {
369
+ component.preview = preview;
370
+ context.invalidate();
371
+ });
372
+ }
373
+
374
+ return buildPatchCallComponent(component, args, theme, context.expanded);
375
+ },
376
+
377
+ renderResult(result: any, options: any, theme: any, context: any) {
378
+ const callComponent: PatchCallComponent | undefined = context.state.callComponent;
379
+ let changed = false;
380
+
381
+ if (callComponent) {
382
+ if (callComponent.settledError !== context.isError) {
383
+ callComponent.settledError = context.isError;
384
+ changed = true;
385
+ }
386
+ if (changed) {
387
+ buildPatchCallComponent(callComponent, context.args, theme, options.expanded);
388
+ if (context.isError) {
389
+ const errorText = result.content
390
+ .filter((c: any) => c.type === "text")
391
+ .map((c: any) => c.text || "")
392
+ .join("\n");
393
+ if (errorText) {
394
+ callComponent.addChild(new Spacer(1));
395
+ callComponent.addChild(new Text(theme.fg("error", errorText), 0, 0));
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ const component = context.lastComponent ?? new Container();
402
+ component.clear();
403
+ return component;
404
+ },
405
+ }));
406
+ }