@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.2

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.
Files changed (235) hide show
  1. package/CHANGELOG.md +316 -1
  2. package/package.json +86 -24
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +116 -30
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +123 -178
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -8
  28. package/src/commit/agentic/index.ts +22 -26
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  31. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  32. package/src/commit/agentic/tools/git-overview.ts +6 -9
  33. package/src/commit/agentic/tools/index.ts +6 -8
  34. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  35. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  36. package/src/commit/agentic/tools/split-commit.ts +4 -4
  37. package/src/commit/agentic/validation.ts +1 -1
  38. package/src/commit/analysis/conventional.ts +4 -4
  39. package/src/commit/analysis/summary.ts +3 -3
  40. package/src/commit/changelog/generate.ts +4 -4
  41. package/src/commit/changelog/index.ts +5 -9
  42. package/src/commit/map-reduce/map-phase.ts +4 -4
  43. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  44. package/src/commit/pipeline.ts +13 -16
  45. package/src/config/keybindings.ts +7 -6
  46. package/src/config/prompt-templates.ts +44 -226
  47. package/src/config/resolve-config-value.ts +4 -2
  48. package/src/config/settings-schema.ts +98 -2
  49. package/src/config/settings.ts +25 -26
  50. package/src/dap/client.ts +674 -0
  51. package/src/dap/config.ts +150 -0
  52. package/src/dap/defaults.json +211 -0
  53. package/src/dap/index.ts +4 -0
  54. package/src/dap/session.ts +1255 -0
  55. package/src/dap/types.ts +600 -0
  56. package/src/debug/log-viewer.ts +3 -2
  57. package/src/discovery/builtin.ts +1 -2
  58. package/src/discovery/codex.ts +2 -2
  59. package/src/discovery/github.ts +2 -1
  60. package/src/discovery/helpers.ts +2 -2
  61. package/src/discovery/opencode.ts +2 -2
  62. package/src/edit/diff.ts +818 -0
  63. package/src/edit/index.ts +309 -0
  64. package/src/edit/line-hash.ts +67 -0
  65. package/src/edit/modes/chunk.ts +454 -0
  66. package/src/{patch → edit/modes}/hashline.ts +741 -361
  67. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  68. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  69. package/src/{patch → edit}/normalize.ts +97 -76
  70. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  71. package/src/exec/bash-executor.ts +4 -2
  72. package/src/exec/idle-timeout-watchdog.ts +126 -0
  73. package/src/exec/non-interactive-env.ts +5 -0
  74. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
  75. package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
  76. package/src/extensibility/custom-commands/loader.ts +1 -2
  77. package/src/extensibility/custom-tools/loader.ts +34 -11
  78. package/src/extensibility/custom-tools/types.ts +1 -1
  79. package/src/extensibility/extensions/loader.ts +9 -4
  80. package/src/extensibility/extensions/runner.ts +24 -1
  81. package/src/extensibility/extensions/types.ts +4 -2
  82. package/src/extensibility/hooks/loader.ts +5 -6
  83. package/src/extensibility/hooks/types.ts +2 -2
  84. package/src/extensibility/plugins/doctor.ts +2 -1
  85. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  86. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  87. package/src/extensibility/slash-commands.ts +3 -7
  88. package/src/index.ts +3 -1
  89. package/src/internal-urls/docs-index.generated.ts +11 -11
  90. package/src/ipy/executor.ts +58 -17
  91. package/src/ipy/gateway-coordinator.ts +6 -4
  92. package/src/ipy/kernel.ts +45 -22
  93. package/src/ipy/runtime.ts +2 -2
  94. package/src/lsp/client.ts +7 -4
  95. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  96. package/src/lsp/config.ts +2 -2
  97. package/src/lsp/defaults.json +688 -154
  98. package/src/lsp/index.ts +234 -45
  99. package/src/lsp/lspmux.ts +2 -2
  100. package/src/lsp/startup-events.ts +13 -0
  101. package/src/lsp/types.ts +12 -1
  102. package/src/lsp/utils.ts +8 -1
  103. package/src/main.ts +125 -47
  104. package/src/memories/index.ts +4 -5
  105. package/src/modes/acp/acp-agent.ts +563 -163
  106. package/src/modes/acp/acp-event-mapper.ts +9 -1
  107. package/src/modes/acp/acp-mode.ts +4 -2
  108. package/src/modes/components/agent-dashboard.ts +3 -4
  109. package/src/modes/components/diff.ts +6 -7
  110. package/src/modes/components/footer.ts +9 -29
  111. package/src/modes/components/hook-editor.ts +3 -3
  112. package/src/modes/components/hook-selector.ts +6 -1
  113. package/src/modes/components/read-tool-group.ts +6 -12
  114. package/src/modes/components/session-observer-overlay.ts +472 -0
  115. package/src/modes/components/settings-defs.ts +24 -0
  116. package/src/modes/components/status-line.ts +15 -61
  117. package/src/modes/components/tool-execution.ts +1 -1
  118. package/src/modes/components/welcome.ts +1 -1
  119. package/src/modes/controllers/btw-controller.ts +2 -2
  120. package/src/modes/controllers/command-controller.ts +4 -2
  121. package/src/modes/controllers/event-controller.ts +59 -2
  122. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  123. package/src/modes/controllers/input-controller.ts +15 -8
  124. package/src/modes/controllers/selector-controller.ts +26 -0
  125. package/src/modes/index.ts +20 -2
  126. package/src/modes/interactive-mode.ts +278 -69
  127. package/src/modes/rpc/host-tools.ts +186 -0
  128. package/src/modes/rpc/rpc-client.ts +178 -13
  129. package/src/modes/rpc/rpc-mode.ts +73 -3
  130. package/src/modes/rpc/rpc-types.ts +53 -1
  131. package/src/modes/session-observer-registry.ts +146 -0
  132. package/src/modes/shared.ts +0 -42
  133. package/src/modes/theme/theme.ts +80 -8
  134. package/src/modes/types.ts +4 -2
  135. package/src/modes/utils/keybinding-matchers.ts +9 -0
  136. package/src/prompts/system/custom-system-prompt.md +5 -0
  137. package/src/prompts/system/system-prompt.md +8 -1
  138. package/src/prompts/tools/chunk-edit.md +219 -0
  139. package/src/prompts/tools/debug.md +43 -0
  140. package/src/prompts/tools/grep.md +3 -0
  141. package/src/prompts/tools/lsp.md +5 -5
  142. package/src/prompts/tools/read-chunk.md +17 -0
  143. package/src/prompts/tools/read.md +19 -5
  144. package/src/sdk.ts +216 -165
  145. package/src/secrets/index.ts +1 -1
  146. package/src/secrets/obfuscator.ts +25 -17
  147. package/src/session/agent-session.ts +381 -286
  148. package/src/session/agent-storage.ts +12 -12
  149. package/src/session/compaction/branch-summarization.ts +3 -3
  150. package/src/session/compaction/compaction.ts +5 -6
  151. package/src/session/compaction/utils.ts +3 -3
  152. package/src/session/history-storage.ts +62 -19
  153. package/src/session/messages.ts +3 -3
  154. package/src/session/session-dump-format.ts +203 -0
  155. package/src/session/session-manager.ts +15 -5
  156. package/src/session/session-storage.ts +4 -2
  157. package/src/session/streaming-output.ts +1 -1
  158. package/src/session/tool-choice-queue.ts +213 -0
  159. package/src/slash-commands/builtin-registry.ts +56 -8
  160. package/src/ssh/connection-manager.ts +2 -2
  161. package/src/ssh/sshfs-mount.ts +5 -5
  162. package/src/stt/downloader.ts +4 -4
  163. package/src/stt/recorder.ts +4 -4
  164. package/src/stt/transcriber.ts +2 -2
  165. package/src/system-prompt.ts +25 -13
  166. package/src/task/agents.ts +5 -6
  167. package/src/task/commands.ts +2 -5
  168. package/src/task/executor.ts +32 -4
  169. package/src/task/index.ts +91 -82
  170. package/src/task/template.ts +2 -2
  171. package/src/task/types.ts +25 -0
  172. package/src/task/worktree.ts +131 -149
  173. package/src/tools/ask.ts +2 -3
  174. package/src/tools/ast-edit.ts +7 -7
  175. package/src/tools/ast-grep.ts +7 -7
  176. package/src/tools/auto-generated-guard.ts +36 -41
  177. package/src/tools/await-tool.ts +2 -2
  178. package/src/tools/bash.ts +5 -23
  179. package/src/tools/browser.ts +4 -5
  180. package/src/tools/calculator.ts +2 -3
  181. package/src/tools/cancel-job.ts +2 -2
  182. package/src/tools/checkpoint.ts +3 -3
  183. package/src/tools/debug.ts +1007 -0
  184. package/src/tools/exit-plan-mode.ts +3 -3
  185. package/src/tools/fetch.ts +67 -3
  186. package/src/tools/find.ts +4 -5
  187. package/src/tools/fs-cache-invalidation.ts +5 -0
  188. package/src/tools/gemini-image.ts +13 -5
  189. package/src/tools/gh.ts +130 -308
  190. package/src/tools/grep.ts +57 -9
  191. package/src/tools/index.ts +44 -22
  192. package/src/tools/inspect-image.ts +4 -4
  193. package/src/tools/output-meta.ts +1 -1
  194. package/src/tools/python.ts +19 -6
  195. package/src/tools/read.ts +211 -146
  196. package/src/tools/render-mermaid.ts +2 -3
  197. package/src/tools/render-utils.ts +20 -6
  198. package/src/tools/renderers.ts +3 -1
  199. package/src/tools/report-tool-issue.ts +80 -0
  200. package/src/tools/resolve.ts +70 -39
  201. package/src/tools/search-tool-bm25.ts +2 -2
  202. package/src/tools/ssh.ts +2 -2
  203. package/src/tools/todo-write.ts +2 -2
  204. package/src/tools/tool-timeouts.ts +1 -0
  205. package/src/tools/write.ts +5 -6
  206. package/src/tui/tree-list.ts +3 -1
  207. package/src/utils/clipboard.ts +80 -0
  208. package/src/utils/commit-message-generator.ts +2 -3
  209. package/src/utils/edit-mode.ts +49 -0
  210. package/src/utils/external-editor.ts +11 -5
  211. package/src/utils/file-display-mode.ts +6 -5
  212. package/src/utils/file-mentions.ts +8 -7
  213. package/src/utils/git.ts +1400 -0
  214. package/src/utils/image-loading.ts +98 -0
  215. package/src/utils/title-generator.ts +2 -3
  216. package/src/utils/tools-manager.ts +6 -6
  217. package/src/web/scrapers/choosealicense.ts +1 -1
  218. package/src/web/search/index.ts +3 -3
  219. package/src/web/search/render.ts +6 -4
  220. package/src/autoresearch/command-initialize.md +0 -34
  221. package/src/commit/git/errors.ts +0 -9
  222. package/src/commit/git/index.ts +0 -210
  223. package/src/commit/git/operations.ts +0 -54
  224. package/src/patch/diff.ts +0 -433
  225. package/src/patch/index.ts +0 -888
  226. package/src/patch/parser.ts +0 -532
  227. package/src/patch/types.ts +0 -292
  228. package/src/prompts/agents/oracle.md +0 -77
  229. package/src/tools/gh-cli.ts +0 -125
  230. package/src/tools/pending-action.ts +0 -49
  231. package/src/utils/child-process.ts +0 -88
  232. package/src/utils/frontmatter.ts +0 -117
  233. package/src/utils/image-input.ts +0 -274
  234. package/src/utils/mime.ts +0 -53
  235. package/src/utils/prompt-format.ts +0 -170
@@ -7,8 +7,33 @@
7
7
 
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- import { resolveToCwd } from "../tools/path-utils";
11
- import { DEFAULT_FUZZY_THRESHOLD, findClosestSequenceMatch, findContextLine, findMatch, seekSequence } from "./fuzzy";
10
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
11
+ import { StringEnum } from "@oh-my-pi/pi-ai";
12
+ import { isEnoent } from "@oh-my-pi/pi-utils";
13
+ import { type Static, Type } from "@sinclair/typebox";
14
+ import {
15
+ type FileDiagnosticsResult,
16
+ flushLspWritethroughBatch,
17
+ type WritethroughCallback,
18
+ type WritethroughDeferredHandle,
19
+ } from "../../lsp";
20
+ import type { ToolSession } from "../../tools";
21
+ import { assertEditableFile } from "../../tools/auto-generated-guard";
22
+ import {
23
+ invalidateFsScanAfterDelete,
24
+ invalidateFsScanAfterRename,
25
+ invalidateFsScanAfterWrite,
26
+ } from "../../tools/fs-cache-invalidation";
27
+ import { outputMeta } from "../../tools/output-meta";
28
+ import { resolveToCwd } from "../../tools/path-utils";
29
+ import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
30
+ import {
31
+ ApplyPatchError,
32
+ type DiffHunk,
33
+ generateUnifiedDiffString,
34
+ normalizeCreateContent,
35
+ parseDiffHunks,
36
+ } from "../diff";
12
37
  import {
13
38
  adjustIndentation,
14
39
  convertLeadingTabsToSpaces,
@@ -18,18 +43,56 @@ import {
18
43
  normalizeToLF,
19
44
  restoreLineEndings,
20
45
  stripBom,
21
- } from "./normalize";
22
- import { normalizeCreateContent, parseHunks } from "./parser";
23
- import type {
24
- ApplyPatchOptions,
25
- ApplyPatchResult,
26
- ContextLineResult,
27
- DiffHunk,
28
- FileSystem,
29
- NormalizedPatchInput,
30
- PatchInput,
31
- } from "./types";
32
- import { ApplyPatchError, normalizePatchInput } from "./types";
46
+ } from "../normalize";
47
+ import type { EditToolDetails, LspBatchRequest } from "../renderer";
48
+ import {
49
+ type ContextLineResult,
50
+ DEFAULT_FUZZY_THRESHOLD,
51
+ findClosestSequenceMatch,
52
+ findContextLine,
53
+ findMatch,
54
+ type SequenceSearchResult,
55
+ seekSequence,
56
+ } from "./replace";
57
+
58
+ export type Operation = "create" | "delete" | "update";
59
+
60
+ export interface PatchInput {
61
+ path: string;
62
+ op: Operation;
63
+ rename?: string;
64
+ diff?: string;
65
+ }
66
+
67
+ export interface FileSystem {
68
+ exists(path: string): Promise<boolean>;
69
+ read(path: string): Promise<string>;
70
+ readBinary?: (path: string) => Promise<Uint8Array>;
71
+ write(path: string, content: string): Promise<void>;
72
+ delete(path: string): Promise<void>;
73
+ mkdir(path: string): Promise<void>;
74
+ }
75
+
76
+ interface FileChange {
77
+ type: Operation;
78
+ path: string;
79
+ newPath?: string;
80
+ oldContent?: string;
81
+ newContent?: string;
82
+ }
83
+
84
+ export interface ApplyPatchResult {
85
+ change: FileChange;
86
+ warnings?: string[];
87
+ }
88
+
89
+ export interface ApplyPatchOptions {
90
+ cwd: string;
91
+ dryRun?: boolean;
92
+ fuzzyThreshold?: number;
93
+ allowFuzzy?: boolean;
94
+ fs?: FileSystem;
95
+ }
33
96
 
34
97
  // ═══════════════════════════════════════════════════════════════════════════
35
98
  // Default File System
@@ -38,14 +101,13 @@ import { ApplyPatchError, normalizePatchInput } from "./types";
38
101
  /** Default filesystem implementation using Bun APIs */
39
102
  export const defaultFileSystem: FileSystem = {
40
103
  async exists(path: string): Promise<boolean> {
41
- return fs.existsSync(path);
104
+ return Bun.file(path).exists();
42
105
  },
43
106
  async read(path: string): Promise<string> {
44
107
  return Bun.file(path).text();
45
108
  },
46
109
  async readBinary(path: string): Promise<Uint8Array> {
47
- const buffer = await Bun.file(path).arrayBuffer();
48
- return new Uint8Array(buffer);
110
+ return fs.promises.readFile(path);
49
111
  },
50
112
  async write(path: string, content: string): Promise<void> {
51
113
  await Bun.write(path, content);
@@ -76,6 +138,71 @@ interface HunkVariant {
76
138
  kind: HunkVariantKind;
77
139
  }
78
140
 
141
+ function isBlankLine(line: string): boolean {
142
+ return line.trim().length === 0;
143
+ }
144
+
145
+ function areEqualLines(left: string[], right: string[]): boolean {
146
+ if (left.length !== right.length) return false;
147
+ for (let i = 0; i < left.length; i++) {
148
+ if (left[i] !== right[i]) return false;
149
+ }
150
+ return true;
151
+ }
152
+
153
+ function areEqualTrimmedLines(left: string[], right: string[]): boolean {
154
+ if (left.length !== right.length) return false;
155
+ for (let i = 0; i < left.length; i++) {
156
+ if (left[i].trim() !== right[i].trim()) return false;
157
+ }
158
+ return true;
159
+ }
160
+
161
+ function getIndentChar(lines: string[]): string {
162
+ for (const line of lines) {
163
+ const ws = getLeadingWhitespace(line);
164
+ if (ws.length > 0) return ws[0];
165
+ }
166
+ return " ";
167
+ }
168
+
169
+ function collectIndentDeltas(oldLines: string[], actualLines: string[]): number[] {
170
+ const deltas: number[] = [];
171
+ const lineCount = Math.min(oldLines.length, actualLines.length);
172
+ for (let i = 0; i < lineCount; i++) {
173
+ const oldLine = oldLines[i];
174
+ const actualLine = actualLines[i];
175
+ if (isBlankLine(oldLine) || isBlankLine(actualLine)) continue;
176
+ deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine));
177
+ }
178
+ return deltas;
179
+ }
180
+
181
+ function applyIndentDelta(lines: string[], delta: number, indentChar: string): string[] {
182
+ return lines.map(line => {
183
+ if (isBlankLine(line)) return line;
184
+ if (delta > 0) return indentChar.repeat(delta) + line;
185
+ const toRemove = Math.min(-delta, countLeadingWhitespace(line));
186
+ return line.slice(toRemove);
187
+ });
188
+ }
189
+
190
+ function canConvertTabsToSpaces(oldLines: string[], actualLines: string[], spacesPerTab: number): boolean {
191
+ const lineCount = Math.min(oldLines.length, actualLines.length);
192
+ for (let i = 0; i < lineCount; i++) {
193
+ const oldLine = oldLines[i];
194
+ const actualLine = actualLines[i];
195
+ if (isBlankLine(oldLine) || isBlankLine(actualLine)) continue;
196
+ const oldIndent = getLeadingWhitespace(oldLine);
197
+ const actualIndent = getLeadingWhitespace(actualLine);
198
+ if (oldIndent.length === 0) continue;
199
+ if (actualIndent.length !== oldIndent.length * spacesPerTab) {
200
+ return false;
201
+ }
202
+ }
203
+ return true;
204
+ }
205
+
79
206
  // ═══════════════════════════════════════════════════════════════════════════
80
207
  // Replacement Computation
81
208
  // ═══════════════════════════════════════════════════════════════════════════
@@ -87,42 +214,17 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
87
214
  }
88
215
 
89
216
  // If pattern already matches actual exactly (including indentation), preserve agent's intended changes
90
- if (patternLines.length === actualLines.length) {
91
- let exactMatch = true;
92
- for (let i = 0; i < patternLines.length; i++) {
93
- if (patternLines[i] !== actualLines[i]) {
94
- exactMatch = false;
95
- break;
96
- }
97
- }
98
- if (exactMatch) {
99
- return newLines;
100
- }
217
+ if (areEqualLines(patternLines, actualLines)) {
218
+ return newLines;
101
219
  }
102
220
 
103
221
  // If the patch is purely an indentation change (same trimmed content), apply exactly as specified
104
- if (patternLines.length === newLines.length) {
105
- let indentationOnly = true;
106
- for (let i = 0; i < patternLines.length; i++) {
107
- if (patternLines[i].trim() !== newLines[i].trim()) {
108
- indentationOnly = false;
109
- break;
110
- }
111
- }
112
- if (indentationOnly) {
113
- return newLines;
114
- }
222
+ if (areEqualTrimmedLines(patternLines, newLines)) {
223
+ return newLines;
115
224
  }
116
225
 
117
226
  // Detect indent character from actual content
118
- let indentChar = " ";
119
- for (const line of actualLines) {
120
- const ws = getLeadingWhitespace(line);
121
- if (ws.length > 0) {
122
- indentChar = ws[0];
123
- break;
124
- }
125
- }
227
+ const indentChar = getIndentChar(actualLines);
126
228
 
127
229
  let patternTabOnly = true;
128
230
  let actualSpaceOnly = true;
@@ -171,9 +273,8 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
171
273
  }
172
274
  }
173
275
 
174
- if (consistent && ratio) {
175
- const converted = convertLeadingTabsToSpaces(newLines.join("\n"), ratio).split("\n");
176
- return converted;
276
+ if (consistent && ratio && canConvertTabsToSpaces(patternLines, actualLines, ratio)) {
277
+ return convertLeadingTabsToSpaces(newLines.join("\n"), ratio).split("\n");
177
278
  }
178
279
  }
179
280
 
@@ -282,20 +383,8 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
282
383
  patternMin = 0;
283
384
  }
284
385
 
285
- let delta: number | undefined;
286
- const deltas: number[] = [];
287
- for (let i = 0; i < Math.min(patternLines.length, actualLines.length); i++) {
288
- const patternLine = patternLines[i];
289
- const actualLine = actualLines[i];
290
- if (patternLine.trim().length === 0 || actualLine.trim().length === 0) continue;
291
- const pIndent = countLeadingWhitespace(patternLine);
292
- const aIndent = countLeadingWhitespace(actualLine);
293
- deltas.push(aIndent - pIndent);
294
- }
295
-
296
- if (deltas.length > 0 && deltas.every(value => value === deltas[0])) {
297
- delta = deltas[0];
298
- }
386
+ const deltas = collectIndentDeltas(patternLines, actualLines);
387
+ const delta = deltas.length > 0 && deltas.every(value => value === deltas[0]) ? deltas[0] : undefined;
299
388
 
300
389
  // Track which actual lines we've used to handle duplicate content correctly
301
390
  const usedActualLines = new Map<string, number>(); // trimmed content -> count used
@@ -328,11 +417,7 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
328
417
  if (delta && delta !== 0) {
329
418
  const newIndent = countLeadingWhitespace(newLine);
330
419
  if (newIndent === patternMin) {
331
- if (delta > 0) {
332
- return indentChar.repeat(delta) + newLine;
333
- }
334
- const toRemove = Math.min(-delta, newIndent);
335
- return newLine.slice(toRemove);
420
+ return applyIndentDelta([newLine], delta, indentChar)[0];
336
421
  }
337
422
  }
338
423
  return newLine;
@@ -737,7 +822,7 @@ function findSequenceWithHint(
737
822
  hintIndex: number | undefined,
738
823
  eof: boolean,
739
824
  allowFuzzy: boolean,
740
- ): import("./types").SequenceSearchResult {
825
+ ): SequenceSearchResult {
741
826
  // Prefer content-based search starting from currentIndex
742
827
  const primaryResult = seekSequence(lines, pattern, currentIndex, eof, { allowFuzzy });
743
828
  if (
@@ -910,6 +995,17 @@ function applyTrailingNewlinePolicy(content: string, hadFinalNewline: boolean):
910
995
  return content.replace(/\n+$/u, "");
911
996
  }
912
997
 
998
+ async function readExistingPatchFile(fileSystem: FileSystem, absolutePath: string, path: string): Promise<string> {
999
+ try {
1000
+ return await fileSystem.read(absolutePath);
1001
+ } catch (error) {
1002
+ if (isEnoent(error)) {
1003
+ throw new ApplyPatchError(`File not found: ${path}`);
1004
+ }
1005
+ throw error;
1006
+ }
1007
+ }
1008
+
913
1009
  /**
914
1010
  * Compute replacements needed to transform originalLines using the diff hunks.
915
1011
  */
@@ -937,6 +1033,7 @@ function computeReplacements(
937
1033
  }
938
1034
  const lineHint = hunk.oldStartLine;
939
1035
  const allowAggressiveFallbacks = hunk.changeContext !== undefined || lineHint !== undefined || hunk.isEndOfFile;
1036
+ const fallbackVariants = filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks);
940
1037
  if (lineHint !== undefined && hunk.changeContext === undefined && !hunk.hasContextLines) {
941
1038
  lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1));
942
1039
  }
@@ -1059,7 +1156,7 @@ function computeReplacements(
1059
1156
  }
1060
1157
 
1061
1158
  if (searchResult.index === undefined || (searchResult.matchCount ?? 0) > 1) {
1062
- for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
1159
+ for (const variant of fallbackVariants) {
1063
1160
  if (variant.oldLines.length === 0) continue;
1064
1161
  const variantResult = findSequenceWithHint(
1065
1162
  originalLines,
@@ -1079,7 +1176,7 @@ function computeReplacements(
1079
1176
  }
1080
1177
 
1081
1178
  if (searchResult.index === undefined && contextIndex !== undefined) {
1082
- for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
1179
+ for (const variant of fallbackVariants) {
1083
1180
  if (variant.oldLines.length !== 1 || variant.newLines.length !== 1) continue;
1084
1181
  const removedLine = variant.oldLines[0];
1085
1182
  const hasSharedDuplicate = hunk.newLines.some(line => line.trim() === removedLine.trim());
@@ -1178,23 +1275,8 @@ function computeReplacements(
1178
1275
  if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) {
1179
1276
  const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy });
1180
1277
  if (secondMatch.index !== undefined) {
1181
- // Extract 3-line previews for each match
1182
- const formatPreview = (startIdx: number) => {
1183
- const contextLines = 2;
1184
- const maxLineLength = 80;
1185
- const start = Math.max(0, startIdx - contextLines);
1186
- const end = Math.min(originalLines.length, startIdx + contextLines + 1);
1187
- const lines = originalLines.slice(start, end);
1188
- return lines
1189
- .map((line, i) => {
1190
- const num = start + i + 1;
1191
- const truncated = line.length > maxLineLength ? `${line.slice(0, maxLineLength - 1)}…` : line;
1192
- return ` ${num} | ${truncated}`;
1193
- })
1194
- .join("\n");
1195
- };
1196
- const preview1 = formatPreview(found);
1197
- const preview2 = formatPreview(secondMatch.index);
1278
+ const preview1 = formatSequenceMatchPreview(originalLines, found);
1279
+ const preview2 = formatSequenceMatchPreview(originalLines, secondMatch.index);
1198
1280
  throw new ApplyPatchError(
1199
1281
  `Found 2 occurrences in ${path}:\n\n${preview1}\n\n${preview2}\n\n` +
1200
1282
  `Add more context lines to disambiguate.`,
@@ -1317,15 +1399,7 @@ function applyHunksToContent(
1317
1399
  }
1318
1400
 
1319
1401
  const content = newLines.join("\n");
1320
-
1321
- // Preserve original trailing newline behavior
1322
- if (hadFinalNewline && !content.endsWith("\n")) {
1323
- return { content: `${content}\n`, warnings };
1324
- }
1325
- if (!hadFinalNewline && content.endsWith("\n")) {
1326
- return { content: content.slice(0, -1), warnings };
1327
- }
1328
- return { content, warnings };
1402
+ return { content: applyTrailingNewlinePolicy(content, hadFinalNewline), warnings };
1329
1403
  }
1330
1404
 
1331
1405
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1336,18 +1410,14 @@ function applyHunksToContent(
1336
1410
  * Apply a patch operation to the filesystem.
1337
1411
  */
1338
1412
  export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
1339
- const normalized = normalizePatchInput(input);
1340
- return applyNormalizedPatch(normalized, options);
1413
+ return applyNormalizedPatch(input, options);
1341
1414
  }
1342
1415
 
1343
1416
  /**
1344
1417
  * Apply a normalized patch operation to the filesystem.
1345
1418
  * @internal
1346
1419
  */
1347
- async function applyNormalizedPatch(
1348
- input: NormalizedPatchInput,
1349
- options: ApplyPatchOptions,
1350
- ): Promise<ApplyPatchResult> {
1420
+ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
1351
1421
  const {
1352
1422
  cwd,
1353
1423
  dryRun = false,
@@ -1358,6 +1428,7 @@ async function applyNormalizedPatch(
1358
1428
 
1359
1429
  const resolvePath = (p: string): string => resolveToCwd(p, cwd);
1360
1430
  const absolutePath = resolvePath(input.path);
1431
+ const op = input.op ?? "update";
1361
1432
 
1362
1433
  if (input.rename) {
1363
1434
  const destPath = resolvePath(input.rename);
@@ -1367,7 +1438,7 @@ async function applyNormalizedPatch(
1367
1438
  }
1368
1439
 
1369
1440
  // Handle CREATE operation
1370
- if (input.op === "create") {
1441
+ if (op === "create") {
1371
1442
  if (!input.diff) {
1372
1443
  throw new ApplyPatchError("Create operation requires diff (file content)");
1373
1444
  }
@@ -1393,12 +1464,8 @@ async function applyNormalizedPatch(
1393
1464
  }
1394
1465
 
1395
1466
  // Handle DELETE operation
1396
- if (input.op === "delete") {
1397
- if (!(await fs.exists(absolutePath))) {
1398
- throw new ApplyPatchError(`File not found: ${input.path}`);
1399
- }
1400
-
1401
- const oldContent = await fs.read(absolutePath);
1467
+ if (op === "delete") {
1468
+ const oldContent = await readExistingPatchFile(fs, absolutePath, input.path);
1402
1469
  if (!dryRun) {
1403
1470
  await fs.delete(absolutePath);
1404
1471
  }
@@ -1417,11 +1484,7 @@ async function applyNormalizedPatch(
1417
1484
  throw new ApplyPatchError("Update operation requires diff (hunks)");
1418
1485
  }
1419
1486
 
1420
- if (!(await fs.exists(absolutePath))) {
1421
- throw new ApplyPatchError(`File not found: ${input.path}`);
1422
- }
1423
-
1424
- const originalContent = await fs.read(absolutePath);
1487
+ const originalContent = await readExistingPatchFile(fs, absolutePath, input.path);
1425
1488
  const { bom: bomFromText, text: strippedContent } = stripBom(originalContent);
1426
1489
  let bom = bomFromText;
1427
1490
  if (!bom && fs.readBinary) {
@@ -1432,7 +1495,7 @@ async function applyNormalizedPatch(
1432
1495
  }
1433
1496
  const lineEnding = detectLineEnding(strippedContent);
1434
1497
  const normalizedContent = normalizeToLF(strippedContent);
1435
- const hunks = parseHunks(input.diff);
1498
+ const hunks = parseDiffHunks(input.diff);
1436
1499
 
1437
1500
  if (hunks.length === 0) {
1438
1501
  throw new ApplyPatchError("Diff contains no hunks");
@@ -1480,3 +1543,243 @@ async function applyNormalizedPatch(
1480
1543
  export async function previewPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
1481
1544
  return applyPatch(input, { ...options, dryRun: true });
1482
1545
  }
1546
+
1547
+ export async function computePatchDiff(
1548
+ input: PatchInput,
1549
+ cwd: string,
1550
+ options?: { fuzzyThreshold?: number; allowFuzzy?: boolean },
1551
+ ): Promise<
1552
+ | {
1553
+ diff: string;
1554
+ firstChangedLine: number | undefined;
1555
+ }
1556
+ | {
1557
+ error: string;
1558
+ }
1559
+ > {
1560
+ try {
1561
+ const result = await previewPatch(input, {
1562
+ cwd,
1563
+ fuzzyThreshold: options?.fuzzyThreshold,
1564
+ allowFuzzy: options?.allowFuzzy,
1565
+ });
1566
+ const oldContent = result.change.oldContent ?? "";
1567
+ const newContent = result.change.newContent ?? "";
1568
+ const normalizedOld = normalizeToLF(stripBom(oldContent).text);
1569
+ const normalizedNew = normalizeToLF(stripBom(newContent).text);
1570
+ if (!normalizedOld && !normalizedNew) {
1571
+ return { diff: "", firstChangedLine: undefined };
1572
+ }
1573
+ return generateUnifiedDiffString(normalizedOld, normalizedNew);
1574
+ } catch (err) {
1575
+ return { error: err instanceof Error ? err.message : String(err) };
1576
+ }
1577
+ }
1578
+
1579
+ export const patchEditSchema = Type.Object({
1580
+ path: Type.String({ description: "File path" }),
1581
+ op: Type.Optional(
1582
+ StringEnum(["create", "delete", "update"], {
1583
+ description: "Operation (default: update)",
1584
+ }),
1585
+ ),
1586
+ rename: Type.Optional(Type.String({ description: "New path for move" })),
1587
+ diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
1588
+ });
1589
+
1590
+ export type PatchParams = Static<typeof patchEditSchema>;
1591
+
1592
+ interface ExecutePatchModeOptions {
1593
+ session: ToolSession;
1594
+ params: PatchParams;
1595
+ signal?: AbortSignal;
1596
+ batchRequest?: LspBatchRequest;
1597
+ allowFuzzy: boolean;
1598
+ fuzzyThreshold: number;
1599
+ writethrough: WritethroughCallback;
1600
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1601
+ }
1602
+
1603
+ export function isPatchParams(params: unknown): params is PatchParams {
1604
+ if (typeof params !== "object" || params === null || !("path" in params)) {
1605
+ return false;
1606
+ }
1607
+ return !("old_text" in params) && !("new_text" in params) && !("edits" in params);
1608
+ }
1609
+
1610
+ class LspFileSystem implements FileSystem {
1611
+ #lastDiagnostics: FileDiagnosticsResult | undefined;
1612
+ #fileCache: Record<string, Bun.BunFile> = {};
1613
+
1614
+ constructor(
1615
+ private readonly writethrough: WritethroughCallback,
1616
+ private readonly signal?: AbortSignal,
1617
+ private readonly batchRequest?: LspBatchRequest,
1618
+ private readonly deferredForPath?: (path: string) => WritethroughDeferredHandle,
1619
+ ) {}
1620
+
1621
+ #getFile(path: string): Bun.BunFile {
1622
+ if (this.#fileCache[path]) {
1623
+ return this.#fileCache[path];
1624
+ }
1625
+ const file = Bun.file(path);
1626
+ this.#fileCache[path] = file;
1627
+ return file;
1628
+ }
1629
+
1630
+ async exists(path: string): Promise<boolean> {
1631
+ return this.#getFile(path).exists();
1632
+ }
1633
+
1634
+ async read(path: string): Promise<string> {
1635
+ return this.#getFile(path).text();
1636
+ }
1637
+
1638
+ async readBinary(path: string): Promise<Uint8Array> {
1639
+ const bytes = await fs.promises.readFile(path);
1640
+ return bytes;
1641
+ }
1642
+
1643
+ async write(path: string, content: string): Promise<void> {
1644
+ const file = this.#getFile(path);
1645
+ const deferredForPath = this.deferredForPath;
1646
+ const result = await this.writethrough(
1647
+ path,
1648
+ content,
1649
+ this.signal,
1650
+ file,
1651
+ this.batchRequest,
1652
+ deferredForPath ? (dst: string) => deferredForPath(dst) : undefined,
1653
+ );
1654
+ if (result) {
1655
+ this.#lastDiagnostics = result;
1656
+ }
1657
+ }
1658
+
1659
+ async delete(path: string): Promise<void> {
1660
+ await this.#getFile(path).unlink();
1661
+ }
1662
+
1663
+ async mkdir(path: string): Promise<void> {
1664
+ await fs.promises.mkdir(path, { recursive: true });
1665
+ }
1666
+
1667
+ getDiagnostics(): FileDiagnosticsResult | undefined {
1668
+ return this.#lastDiagnostics;
1669
+ }
1670
+ }
1671
+
1672
+ function mergeDiagnosticsWithWarnings(
1673
+ diagnostics: FileDiagnosticsResult | undefined,
1674
+ warnings: string[],
1675
+ ): FileDiagnosticsResult | undefined {
1676
+ if (warnings.length === 0) return diagnostics;
1677
+ const warningMessages = warnings.map(warning => `patch: ${warning}`);
1678
+ if (!diagnostics) {
1679
+ return {
1680
+ server: "patch",
1681
+ messages: warningMessages,
1682
+ summary: `Patch warnings: ${warnings.length}`,
1683
+ errored: false,
1684
+ };
1685
+ }
1686
+ return {
1687
+ ...diagnostics,
1688
+ messages: [...warningMessages, ...diagnostics.messages],
1689
+ summary: `${diagnostics.summary}; Patch warnings: ${warnings.length}`,
1690
+ };
1691
+ }
1692
+
1693
+ export async function executePatchMode(
1694
+ options: ExecutePatchModeOptions,
1695
+ ): Promise<AgentToolResult<EditToolDetails, typeof patchEditSchema>> {
1696
+ const {
1697
+ session,
1698
+ params,
1699
+ signal,
1700
+ batchRequest,
1701
+ allowFuzzy,
1702
+ fuzzyThreshold,
1703
+ writethrough,
1704
+ beginDeferredDiagnosticsForPath,
1705
+ } = options;
1706
+ const { path, op: rawOp, rename, diff } = params;
1707
+
1708
+ const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
1709
+
1710
+ enforcePlanModeWrite(session, path, { op, move: rename });
1711
+ const resolvedPath = resolvePlanPath(session, path);
1712
+ const resolvedRename = rename ? resolvePlanPath(session, rename) : undefined;
1713
+
1714
+ if (path.endsWith(".ipynb")) {
1715
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1716
+ }
1717
+ if (rename?.endsWith(".ipynb")) {
1718
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1719
+ }
1720
+
1721
+ await assertEditableFile(resolvedPath, path);
1722
+
1723
+ const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff };
1724
+ const patchFileSystem = new LspFileSystem(writethrough, signal, batchRequest, beginDeferredDiagnosticsForPath);
1725
+ const result = await applyPatch(input, {
1726
+ cwd: session.cwd,
1727
+ fs: patchFileSystem,
1728
+ fuzzyThreshold,
1729
+ allowFuzzy,
1730
+ });
1731
+
1732
+ if (resolvedRename) {
1733
+ invalidateFsScanAfterRename(resolvedPath, resolvedRename);
1734
+ } else if (result.change.type === "delete") {
1735
+ invalidateFsScanAfterDelete(resolvedPath);
1736
+ } else {
1737
+ invalidateFsScanAfterWrite(resolvedPath);
1738
+ }
1739
+ const effectiveRename = result.change.newPath ? rename : undefined;
1740
+
1741
+ let diffResult: { diff: string; firstChangedLine: number | undefined } = {
1742
+ diff: "",
1743
+ firstChangedLine: undefined,
1744
+ };
1745
+ if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
1746
+ const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
1747
+ const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
1748
+ diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
1749
+ }
1750
+
1751
+ let resultText: string;
1752
+ switch (result.change.type) {
1753
+ case "create":
1754
+ resultText = `Created ${path}`;
1755
+ break;
1756
+ case "delete":
1757
+ resultText = `Deleted ${path}`;
1758
+ break;
1759
+ case "update":
1760
+ resultText = effectiveRename ? `Updated and moved ${path} to ${effectiveRename}` : `Updated ${path}`;
1761
+ break;
1762
+ }
1763
+
1764
+ let diagnostics = patchFileSystem.getDiagnostics();
1765
+ if (op === "delete" && batchRequest?.flush) {
1766
+ const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, session.cwd, signal);
1767
+ diagnostics ??= flushedDiagnostics;
1768
+ }
1769
+ const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, result.warnings ?? []);
1770
+ const meta = outputMeta()
1771
+ .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? [])
1772
+ .get();
1773
+
1774
+ return {
1775
+ content: [{ type: "text", text: resultText }],
1776
+ details: {
1777
+ diff: diffResult.diff,
1778
+ firstChangedLine: diffResult.firstChangedLine,
1779
+ diagnostics: mergedDiagnostics,
1780
+ op,
1781
+ move: effectiveRename,
1782
+ meta,
1783
+ },
1784
+ };
1785
+ }