@cortexkit/aft-opencode 0.3.0 → 0.4.1

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 (70) hide show
  1. package/dist/bridge.d.ts +8 -0
  2. package/dist/bridge.d.ts.map +1 -1
  3. package/dist/bridge.js +45 -2
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/config.d.ts +1 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +11 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/downloader.d.ts.map +1 -1
  10. package/dist/downloader.js +51 -15
  11. package/dist/downloader.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +56 -16
  14. package/dist/index.js.map +1 -1
  15. package/dist/normalize-schemas.d.ts +16 -0
  16. package/dist/normalize-schemas.d.ts.map +1 -0
  17. package/dist/normalize-schemas.js +45 -0
  18. package/dist/normalize-schemas.js.map +1 -0
  19. package/dist/patch-parser.d.ts.map +1 -1
  20. package/dist/patch-parser.js +10 -0
  21. package/dist/patch-parser.js.map +1 -1
  22. package/dist/platform.d.ts +21 -0
  23. package/dist/platform.d.ts.map +1 -0
  24. package/dist/platform.js +31 -0
  25. package/dist/platform.js.map +1 -0
  26. package/dist/pool.d.ts.map +1 -1
  27. package/dist/pool.js +14 -5
  28. package/dist/pool.js.map +1 -1
  29. package/dist/resolver.d.ts.map +1 -1
  30. package/dist/resolver.js +6 -9
  31. package/dist/resolver.js.map +1 -1
  32. package/dist/tools/ast.d.ts.map +1 -1
  33. package/dist/tools/ast.js +52 -59
  34. package/dist/tools/ast.js.map +1 -1
  35. package/dist/tools/hoisted.d.ts +1 -1
  36. package/dist/tools/hoisted.d.ts.map +1 -1
  37. package/dist/tools/hoisted.js +333 -230
  38. package/dist/tools/hoisted.js.map +1 -1
  39. package/dist/tools/imports.d.ts.map +1 -1
  40. package/dist/tools/imports.js +40 -30
  41. package/dist/tools/imports.js.map +1 -1
  42. package/dist/tools/lsp.d.ts +1 -2
  43. package/dist/tools/lsp.d.ts.map +1 -1
  44. package/dist/tools/lsp.js +31 -13
  45. package/dist/tools/lsp.js.map +1 -1
  46. package/dist/tools/navigation.d.ts.map +1 -1
  47. package/dist/tools/navigation.js +23 -14
  48. package/dist/tools/navigation.js.map +1 -1
  49. package/dist/tools/permissions.d.ts +8 -0
  50. package/dist/tools/permissions.d.ts.map +1 -0
  51. package/dist/tools/permissions.js +50 -0
  52. package/dist/tools/permissions.js.map +1 -0
  53. package/dist/tools/reading.d.ts +1 -2
  54. package/dist/tools/reading.d.ts.map +1 -1
  55. package/dist/tools/reading.js +191 -12
  56. package/dist/tools/reading.js.map +1 -1
  57. package/dist/tools/refactoring.d.ts.map +1 -1
  58. package/dist/tools/refactoring.js +72 -34
  59. package/dist/tools/refactoring.js.map +1 -1
  60. package/dist/tools/safety.d.ts.map +1 -1
  61. package/dist/tools/safety.js +34 -12
  62. package/dist/tools/safety.js.map +1 -1
  63. package/dist/tools/structure.d.ts.map +1 -1
  64. package/dist/tools/structure.js +66 -31
  65. package/dist/tools/structure.js.map +1 -1
  66. package/package.json +7 -7
  67. package/dist/tools/editing.d.ts +0 -8
  68. package/dist/tools/editing.d.ts.map +0 -1
  69. package/dist/tools/editing.js +0 -8
  70. package/dist/tools/editing.js.map +0 -1
@@ -59,63 +59,49 @@ function buildUnifiedDiff(fp, before, after) {
59
59
  }
60
60
  const z = tool.schema;
61
61
  // ---------------------------------------------------------------------------
62
- // Descriptions verbose because .describe() on Zod args does NOT reach the agent.
63
- // The description string is the ONLY documentation the LLM sees.
62
+ // Tool descriptions focus on behavior, modes, and return values.
63
+ // Parameter docs live in Zod .describe() and reach the LLM via JSON Schema.
64
64
  // ---------------------------------------------------------------------------
65
- const READ_DESCRIPTION = `Read files, directories, or inspect code symbols with call-graph annotations.
65
+ const READ_DESCRIPTION = `Read file contents or list directory entries.
66
66
 
67
- **Modes** (determined by which parameters you provide):
68
-
69
- 1. **Read file** (default) — pass \`filePath\` only
70
- Returns line-numbered content. Use \`start_line\`/\`end_line\` to read specific sections.
71
- Example: \`{ "filePath": "src/app.ts" }\` or \`{ "filePath": "src/app.ts", "start_line": 50, "end_line": 100 }\`
72
-
73
- 2. **Inspect symbol** — pass \`filePath\` + \`symbol\`
74
- Returns the full source of a named symbol (function, class, type) with call-graph
75
- annotations showing what it calls and what calls it. Includes surrounding context lines.
76
- Example: \`{ "filePath": "src/app.ts", "symbol": "handleRequest" }\`
77
-
78
- 3. **Inspect multiple symbols** — pass \`filePath\` + \`symbols\` array
79
- Returns multiple symbols in one call. More efficient than separate calls.
80
- Example: \`{ "filePath": "src/app.ts", "symbols": ["Config", "createApp"] }\`
67
+ Use either startLine/endLine OR offset/limit to read a section of a file.
81
68
 
82
- 4. **List directory** — pass \`filePath\` pointing to a directory
83
- Returns sorted entries, directories have trailing \`/\`.
84
- Example: \`{ "filePath": "src/" }\`
85
-
86
- **Parameters:**
87
- - \`filePath\` (string, required): Path to file or directory (absolute or relative to project root)
88
- - \`symbol\` (string): Name of a single symbol to inspect — returns full source + call graph
89
- - \`symbols\` (string[]): Array of symbol names to inspect in one call
90
- - \`start_line\` (number): 1-based line to start reading from (default: 1)
91
- - \`end_line\` (number): 1-based line to stop reading at, inclusive
92
- - \`limit\` (number): Max lines to return (default: 2000). Ignored when end_line is set.
93
- - \`context_lines\` (number): Lines of context around symbols (default: 3)
94
-
95
- **Behavior:**
69
+ Behavior:
70
+ - Returns line-numbered content (e.g., "1: const x = 1")
96
71
  - Lines longer than 2000 characters are truncated
97
- - Output capped at 50KB — use start_line/end_line to page through large files
72
+ - Output capped at 50KB
98
73
  - Binary files are auto-detected and return a size-only message
99
- - Symbol mode includes \`calls_out\` and \`called_by\` annotations from call-graph analysis
100
- - For Markdown files, use heading text as symbol name (e.g., symbol: "Architecture")`;
74
+ - Image files (.png, .jpg, .gif, .webp, etc.) and PDFs return a metadata string (format, size, path) — no file content is returned
75
+ - Directories return sorted entries with trailing / for subdirectories
76
+
77
+ Examples:
78
+ Read full file: { "filePath": "src/app.ts" }
79
+ Read lines 50-100: { "filePath": "src/app.ts", "startLine": 50, "endLine": 100 }
80
+ Read 30 lines from line 200: { "filePath": "src/app.ts", "offset": 200, "limit": 30 }
81
+ List directory: { "filePath": "src/" }
82
+
83
+ Returns: Line-numbered file content string. For directories: newline-joined sorted entries. For binary files: size/message string.`;
101
84
  /**
102
- * Creates the unified read tool. Registers as "read" when hoisted, "aft_read" when not.
85
+ * Creates the simple read tool. Registers as "read" when hoisted, "aft_read" when not.
103
86
  */
104
87
  export function createReadTool(ctx) {
105
88
  return {
106
89
  description: READ_DESCRIPTION,
107
90
  args: {
108
- filePath: z.string(),
109
- symbol: z.string().optional(),
110
- symbols: z.array(z.string()).optional(),
111
- start_line: z.number().optional(),
112
- end_line: z.number().optional(),
113
- limit: z.number().optional(),
114
- context_lines: z.number().optional(),
91
+ filePath: z
92
+ .string()
93
+ .describe("Path to file or directory (absolute or relative to project root)"),
94
+ startLine: z.number().optional().describe("1-based line to start reading from"),
95
+ endLine: z.number().optional().describe("1-based line to stop reading at (inclusive)"),
96
+ limit: z.number().optional().describe("Max lines to return (default: 2000)"),
97
+ offset: z
98
+ .number()
99
+ .optional()
100
+ .describe("1-based line number to start reading from (use with limit). Ignored if startLine is provided"),
115
101
  },
116
102
  execute: async (args, context) => {
117
103
  const bridge = ctx.pool.getBridge(context.directory);
118
- const file = (args.filePath ?? args.file);
104
+ const file = args.filePath;
119
105
  // Resolve relative paths
120
106
  const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
121
107
  // Permission check
@@ -174,59 +160,23 @@ export function createReadTool(ctx) {
174
160
  }
175
161
  return `${msg} (${ext.slice(1).toUpperCase()}, ${sizeStr}). File: ${filePath}`;
176
162
  }
177
- const _relPath = path.relative(context.worktree, filePath);
178
- // Route: symbol/symbols → zoom command, everything else → read command
179
- const hasSymbol = typeof args.symbol === "string" && args.symbol.length > 0;
180
- const hasSymbols = Array.isArray(args.symbols) && args.symbols.length > 0;
181
- if (hasSymbol || hasSymbols) {
182
- // Symbol mode zoom command
183
- const params = { file: filePath };
184
- if (hasSymbol)
185
- params.symbol = args.symbol;
186
- if (hasSymbols)
187
- params.symbols = args.symbols;
188
- if (args.start_line !== undefined)
189
- params.start_line = args.start_line;
190
- if (args.end_line !== undefined)
191
- params.end_line = args.end_line;
192
- if (args.context_lines !== undefined)
193
- params.context_lines = args.context_lines;
194
- const data = await bridge.send("zoom", params);
195
- const callID = getCallID(context);
196
- if (callID)
197
- storeToolMetadata(context.sessionID, callID, {
198
- title: relativeToWorktree(filePath, context.worktree),
199
- metadata: { title: relativeToWorktree(filePath, context.worktree) },
200
- });
201
- return JSON.stringify(data);
202
- }
203
- // Line-range mode with start_line + end_line → also zoom (has context_before/after)
204
- if (args.start_line !== undefined && args.end_line !== undefined) {
205
- const params = {
206
- file: filePath,
207
- start_line: args.start_line,
208
- end_line: args.end_line,
209
- };
210
- if (args.context_lines !== undefined)
211
- params.context_lines = args.context_lines;
212
- const data = await bridge.send("zoom", params);
213
- const lineCallID = getCallID(context);
214
- if (lineCallID) {
215
- const dp = relativeToWorktree(filePath, context.worktree);
216
- storeToolMetadata(context.sessionID, lineCallID, {
217
- title: `${dp}:${args.start_line}-${args.end_line}`,
218
- metadata: { title: `${dp}:${args.start_line}-${args.end_line}` },
219
- });
163
+ // Normalize offset/limit to startLine/endLine (backward compat with opencode's read)
164
+ let startLine = args.startLine;
165
+ let endLine = args.endLine;
166
+ if (startLine === undefined && args.offset !== undefined) {
167
+ startLine = args.offset;
168
+ if (args.limit !== undefined) {
169
+ endLine = Number(args.offset) + Number(args.limit) - 1;
220
170
  }
221
- return JSON.stringify(data);
222
171
  }
223
- // Plain read mode read command (line-numbered, truncated, binary/dir detection)
172
+ // Always use Rust read command simple file reading only
224
173
  const params = { file: filePath };
225
- if (args.start_line !== undefined)
226
- params.start_line = args.start_line;
227
- if (args.end_line !== undefined)
228
- params.end_line = args.end_line;
229
- if (args.limit !== undefined)
174
+ if (startLine !== undefined)
175
+ params.start_line = startLine;
176
+ if (endLine !== undefined)
177
+ params.end_line = endLine;
178
+ // Only send limit if we did NOT convert offset to startLine/endLine
179
+ if (args.limit !== undefined && args.offset === undefined)
230
180
  params.limit = args.limit;
231
181
  const data = await bridge.send("read", params);
232
182
  const readCallID = getCallID(context);
@@ -254,7 +204,7 @@ export function createReadTool(ctx) {
254
204
  let output = data.content;
255
205
  // Add navigation hint if truncated
256
206
  if (data.truncated) {
257
- output += `\n(Showing lines ${data.start_line}-${data.end_line} of ${data.total_lines}. Use start_line/end_line to read other sections.)`;
207
+ output += `\n(Showing lines ${data.start_line}-${data.end_line} of ${data.total_lines}. Use startLine/endLine to read other sections.)`;
258
208
  }
259
209
  return output;
260
210
  },
@@ -263,33 +213,35 @@ export function createReadTool(ctx) {
263
213
  // ---------------------------------------------------------------------------
264
214
  // WRITE tool
265
215
  // ---------------------------------------------------------------------------
266
- const WRITE_DESCRIPTION = `Write content to a file, creating it (and parent directories) if needed.
216
+ function getWriteDescription(editToolName) {
217
+ return `Write content to a file, creating it (and parent directories) if needed.
267
218
 
268
219
  Automatically creates parent directories. Backs up existing files before overwriting.
269
220
  If the project has a formatter configured (biome, prettier, rustfmt, etc.), the file
270
221
  is auto-formatted after writing. Returns inline LSP diagnostics when available.
271
222
 
272
- **Parameters:**
273
- - \`filePath\` (string, required): Path to the file to write (absolute or relative to project root)
274
- - \`content\` (string, required): The full content to write to the file
275
-
276
223
  **Behavior:**
277
224
  - Creates parent directories automatically (no need to mkdir first)
278
225
  - Existing files are backed up before overwriting (recoverable via aft_safety undo)
279
226
  - Auto-formats using project formatter if configured (biome.json, .prettierrc, etc.)
280
- - Returns LSP diagnostics inline if type errors are introduced
227
+ - Returns LSP error-level diagnostics inline if type errors are introduced
281
228
  - Use this for creating new files or completely replacing file contents
282
- - For partial edits (find/replace), use the \`edit\` tool instead`;
283
- function createWriteTool(ctx) {
229
+ - For partial edits (find/replace), use the \`${editToolName}\` tool instead
230
+
231
+ Returns: Status message string (for example: "Created new file. Auto-formatted.") with optional inline LSP error lines.`;
232
+ }
233
+ function createWriteTool(ctx, editToolName = "edit") {
284
234
  return {
285
- description: WRITE_DESCRIPTION,
235
+ description: getWriteDescription(editToolName),
286
236
  args: {
287
- filePath: z.string(),
288
- content: z.string(),
237
+ filePath: z
238
+ .string()
239
+ .describe("Path to the file to write (absolute or relative to project root)"),
240
+ content: z.string().describe("The full content to write to the file"),
289
241
  },
290
242
  execute: async (args, context) => {
291
243
  const bridge = ctx.pool.getBridge(context.directory);
292
- const file = (args.filePath ?? args.file);
244
+ const file = args.filePath;
293
245
  const content = args.content;
294
246
  const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
295
247
  const relPath = path.relative(context.worktree, filePath);
@@ -350,77 +302,93 @@ function createWriteTool(ctx) {
350
302
  // ---------------------------------------------------------------------------
351
303
  // EDIT tool
352
304
  // ---------------------------------------------------------------------------
353
- const EDIT_DESCRIPTION = `Edit a file by finding and replacing text, or by targeting named symbols.
305
+ function getEditDescription(writeToolName) {
306
+ return `Edit a file by finding and replacing text, or by targeting named symbols.
354
307
 
355
308
  **Modes** (determined by which parameters you provide):
356
309
 
357
- 1. **Find and replace** pass \`filePath\` + \`match\` + \`replacement\`
358
- Finds the exact text in \`match\` and replaces it with \`replacement\`.
359
- Returns an error if multiple matches are found (use \`occurrence\` to select one,
360
- or \`replace_all: true\` to replace all).
361
- Example: \`{ "filePath": "src/app.ts", "match": "const x = 1", "replacement": "const x = 2" }\`
310
+ Mode priority: operations > edits > symbol (without oldString) > oldString (find/replace) > content-only (${writeToolName})
362
311
 
363
- 2. **Replace all occurrences** — add \`replace_all: true\`
364
- Replaces every occurrence of \`match\` in the file.
365
- Example: \`{ "filePath": "src/app.ts", "match": "oldName", "replacement": "newName", "replace_all": true }\`
312
+ 1. **Multi-file transaction** — pass \`operations\` array
313
+ Edits across multiple files with checkpoint-based rollback on failure.
314
+ Each operation: \`{ "file": "path", "command": "edit_match" | "write", ... }\`.
315
+ For \`edit_match\`: include \`match\`, \`replacement\`. For \`write\`: include \`content\`.
316
+ Example: \`{ "operations": [{ "file": "a.ts", "command": "edit_match", "match": "old", "replacement": "new" }, { "file": "b.ts", "command": "write", "content": "..." }] }\`
366
317
 
367
- 3. **Select specific occurrence** — add \`occurrence: N\` (0-indexed)
368
- When multiple matches exist, select the Nth one (0 = first, 1 = second, etc.).
369
- Example: \`{ "filePath": "src/app.ts", "match": "TODO", "replacement": "DONE", "occurrence": 0 }\`
318
+ 2. **Batch edits** — pass \`filePath\` + \`edits\` array
319
+ Multiple edits in one file atomically. Each edit is either:
320
+ - \`{ "oldString": "old", "newString": "new" }\` find/replace
321
+ - \`{ "startLine": 5, "endLine": 7, "content": "new lines" }\` — replace line range (1-based, both inclusive)
322
+ Set content to empty string to delete lines.
370
323
 
371
- 4. **Symbol replace** — pass \`filePath\` + \`symbol\` + \`content\`
324
+ 3. **Symbol replace** — pass \`filePath\` + \`symbol\` + \`content\`
372
325
  Replaces an entire named symbol (function, class, type) with new content.
373
326
  Includes decorators, attributes, and doc comments in the replacement range.
327
+ **Important:** You must NOT provide \`oldString\` when using symbol mode — if present, the tool silently falls back to find/replace mode.
374
328
  Example: \`{ "filePath": "src/app.ts", "symbol": "handleRequest", "content": "function handleRequest() { ... }" }\`
375
329
 
376
- 5. **Batch edits** — pass \`filePath\` + \`edits\` array
377
- Multiple edits in one file atomically. Each edit is either:
378
- - \`{ "match": "old", "replacement": "new" }\` — find/replace
379
- - \`{ "line_start": 5, "line_end": 7, "content": "new lines" }\` replace line range (1-based, inclusive)
380
- Set content to empty string to delete lines.
381
- Example: \`{ "filePath": "src/app.ts", "edits": [{ "match": "foo", "replacement": "bar" }, { "line_start": 10, "line_end": 12, "content": "" }] }\`
330
+ 4. **Find and replace** — pass \`filePath\` + \`oldString\` + \`newString\`
331
+ Finds the exact text in \`oldString\` and replaces it with \`newString\`.
332
+ Supports fuzzy matching (handles whitespace differences automatically).
333
+ If multiple matches exist, specify which one with \`occurrence\` or use \`replaceAll: true\`.
334
+ Example: \`{ "filePath": "src/app.ts", "oldString": "const x = 1", "newString": "const x = 2" }\`
382
335
 
383
- 6. **Multi-file transaction** — pass \`operations\` array
384
- Atomic edits across multiple files with rollback on failure.
385
- Example: \`{ "operations": [{ "file": "a.ts", "command": "write", "content": "..." }, { "file": "b.ts", "command": "edit_match", "match": "x", "replacement": "y" }] }\`
336
+ 5. **Replace all occurrences** — add \`replaceAll: true\`
337
+ Replaces every occurrence of \`oldString\` in the file.
338
+ Example: \`{ "filePath": "src/app.ts", "oldString": "oldName", "newString": "newName", "replaceAll": true }\`
386
339
 
387
- 7. **Glob replace** — pass \`filePath\` as glob pattern (e.g. \`"src/**/*.ts"\`) + \`match\` + \`replacement\`
388
- Replaces across all matching files. Must use \`replace_all: true\`.
389
- Example: \`{ "filePath": "src/**/*.ts", "match": "@deprecated", "replacement": "", "replace_all": true }\`
340
+ 6. **Select specific occurrence** — add \`occurrence: N\` (0-indexed)
341
+ When multiple matches exist, select the Nth one (0 = first, 1 = second, etc.).
342
+ Example: \`{ "filePath": "src/app.ts", "oldString": "TODO", "newString": "DONE", "occurrence": 0 }\`
390
343
 
391
- **Parameters:**
392
- - \`filePath\` (string): Path to file, or glob pattern for multi-file operations
393
- - \`match\` (string): Text to find (exact match). For multi-line, use actual newlines.
394
- - \`replacement\` (string): Text to replace with
395
- - \`replace_all\` (boolean): Replace all occurrences instead of erroring on ambiguity
396
- - \`occurrence\` (number): 0-indexed occurrence to replace when multiple matches exist
397
- - \`symbol\` (string): Named symbol to replace (function, class, type)
398
- - \`content\` (string): New content for symbol replace or file write
399
- - \`edits\` (array): Batch edits — array of { match, replacement } or { line_start, line_end, content }
400
- - \`operations\` (array): Transaction — array of { file, command, ... } for atomic multi-file edits
401
- - \`dry_run\` (boolean): Preview changes without applying (returns diff)
402
- - \`diagnostics\` (boolean): Return inline LSP diagnostics after the edit
344
+ Note: Modes 5 and 6 are options on mode 4 (find/replace) — they require \`oldString\`.
403
345
 
404
346
  **Behavior:**
405
347
  - Backs up files before editing (recoverable via aft_safety undo)
406
348
  - Auto-formats using project formatter if configured
407
349
  - Tree-sitter syntax validation on all edits
408
- - Symbol replace includes decorators, attributes, and doc comments in range`;
409
- function createEditTool(ctx) {
350
+ - Symbol replace includes decorators, attributes, and doc comments in range
351
+ - LSP error-level diagnostics are returned automatically after non-dry-run edits
352
+
353
+ Returns: JSON string for the selected edit mode. Dry runs return diff data; non-dry-run edits may append inline LSP error lines.
354
+
355
+ Common response fields: success (boolean), diff (object with before/after), backup_id (string), syntax_valid (boolean). Exact fields vary by mode.`;
356
+ // Note: The Returns section intentionally stays high-level because per-mode JSON shapes
357
+ // vary by Rust command and documenting each would bloat the description for minimal gain.
358
+ // Agents can parse the JSON response generically — key fields include 'success' and 'diff'.
359
+ }
360
+ function createEditTool(ctx, writeToolName = "write") {
410
361
  return {
411
- description: EDIT_DESCRIPTION,
362
+ description: getEditDescription(writeToolName),
412
363
  args: {
413
- filePath: z.string().optional(),
414
- match: z.string().optional(),
415
- replacement: z.string().optional(),
416
- replace_all: z.boolean().optional(),
417
- occurrence: z.number().optional(),
418
- symbol: z.string().optional(),
419
- content: z.string().optional(),
420
- edits: z.array(z.record(z.string(), z.unknown())).optional(),
421
- operations: z.array(z.record(z.string(), z.unknown())).optional(),
422
- dry_run: z.boolean().optional(),
423
- diagnostics: z.boolean().optional(),
364
+ filePath: z
365
+ .string()
366
+ .optional()
367
+ .describe("Path to the file to edit (absolute or relative to project root). Required for all modes except 'operations' multi-file transactions"),
368
+ oldString: z.string().optional().describe("Text to find (exact match, with fuzzy fallback)"),
369
+ newString: z
370
+ .string()
371
+ .optional()
372
+ .describe("Text to replace with (omit or set to empty string to delete the matched text)"),
373
+ replaceAll: z.boolean().optional().describe("Replace all occurrences"),
374
+ occurrence: z
375
+ .number()
376
+ .optional()
377
+ .describe("0-indexed occurrence to replace when multiple matches exist"),
378
+ symbol: z.string().optional().describe("Named symbol to replace (function, class, type)"),
379
+ content: z.string().optional().describe("New content for symbol replace or file write"),
380
+ edits: z
381
+ .array(z.record(z.string(), z.unknown()))
382
+ .optional()
383
+ .describe("Batch edits — array of { oldString: string, newString: string } or { startLine: number (1-based), endLine: number (1-based, inclusive), content: string }"),
384
+ operations: z
385
+ .array(z.record(z.string(), z.unknown()))
386
+ .optional()
387
+ .describe("Transaction — array of { file: string, command: 'edit_match' | 'write', match?: string, replacement?: string, content?: string } for multi-file edits with rollback. Note: uses 'file'/'match'/'replacement' (not filePath/oldString/newString)"),
388
+ dryRun: z
389
+ .boolean()
390
+ .optional()
391
+ .describe("Preview changes without applying (returns diff, default: false)"),
424
392
  },
425
393
  execute: async (args, context) => {
426
394
  const bridge = ctx.pool.getBridge(context.directory);
@@ -440,12 +408,14 @@ function createEditTool(ctx) {
440
408
  ? op.file
441
409
  : path.resolve(context.directory, op.file),
442
410
  }));
443
- const data = await bridge.send("transaction", { operations: resolvedOps });
411
+ const params = { operations: resolvedOps };
412
+ params.dry_run = args.dryRun === true;
413
+ const data = await bridge.send("transaction", params);
444
414
  return JSON.stringify(data);
445
415
  }
446
- const file = (args.filePath ?? args.file);
416
+ const file = args.filePath;
447
417
  if (!file)
448
- throw new Error("'file' parameter is required");
418
+ throw new Error("'filePath' parameter is required");
449
419
  const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
450
420
  const relPath = path.relative(context.worktree, filePath);
451
421
  await context.ask({
@@ -458,48 +428,64 @@ function createEditTool(ctx) {
458
428
  // Route to appropriate Rust command
459
429
  let command;
460
430
  if (Array.isArray(args.edits)) {
461
- // Batch mode
431
+ // Batch mode — translate camelCase to snake_case for Rust
462
432
  command = "batch";
463
- params.edits = args.edits;
433
+ params.edits = args.edits.map((edit) => {
434
+ const translated = {};
435
+ for (const [key, value] of Object.entries(edit)) {
436
+ if (key === "oldString")
437
+ translated.match = value;
438
+ else if (key === "newString")
439
+ translated.replacement = value;
440
+ else if (key === "startLine")
441
+ translated.line_start = value;
442
+ else if (key === "endLine")
443
+ translated.line_end = value;
444
+ else
445
+ translated[key] = value;
446
+ }
447
+ return translated;
448
+ });
464
449
  }
465
- else if (typeof args.symbol === "string") {
466
- // Symbol replace
450
+ else if (typeof args.symbol === "string" &&
451
+ typeof args.oldString !== "string" &&
452
+ args.content !== undefined) {
453
+ // Symbol replace — only when content is provided and oldString is NOT present
454
+ // (agents often pass symbol as "what to search for", not "replace whole symbol")
467
455
  command = "edit_symbol";
468
456
  params.symbol = args.symbol;
469
457
  params.operation = "replace";
470
- if (args.content !== undefined)
471
- params.content = args.content;
458
+ params.content = args.content;
472
459
  }
473
- else if (typeof args.match === "string") {
474
- // Find/replace mode (including glob)
460
+ else if (typeof args.oldString === "string") {
461
+ // Find/replace mode — default newString to "" (deletion) if not provided
475
462
  command = "edit_match";
476
- params.match = args.match;
477
- if (args.replacement !== undefined)
478
- params.replacement = args.replacement;
479
- if (args.replace_all !== undefined)
480
- params.replace_all = args.replace_all;
463
+ params.match = args.oldString;
464
+ params.replacement = args.newString ?? "";
465
+ if (args.replaceAll !== undefined)
466
+ params.replace_all = args.replaceAll;
481
467
  if (args.occurrence !== undefined)
482
468
  params.occurrence = args.occurrence;
483
469
  }
484
470
  else if (typeof args.content === "string") {
485
- // Write mode (full file content)
471
+ // Write mode
486
472
  command = "write";
487
473
  params.content = args.content;
488
474
  params.create_dirs = true;
489
475
  }
490
476
  else {
491
- throw new Error("Provide 'match' + 'replacement', 'symbol' + 'content', 'edits' array, or 'content' for write");
477
+ throw new Error("Provide 'oldString' + 'newString', 'symbol' + 'content', 'edits' array, or 'content' for write");
492
478
  }
493
- if (args.dry_run)
479
+ if (args.dryRun)
494
480
  params.dry_run = true;
495
- if (args.diagnostics)
481
+ if (!args.dryRun)
496
482
  params.diagnostics = true;
497
483
  // Request diff from Rust for UI metadata (avoids extra file reads in TS)
498
- if (!args.dry_run)
484
+ if (!args.dryRun)
499
485
  params.include_diff = true;
500
486
  const data = await bridge.send(command, params);
501
487
  // Store metadata for tool.execute.after hook (fromPlugin overwrites context.metadata)
502
- if (!args.dry_run && data.ok && data.diff) {
488
+ if (!args.dryRun && data.success && data.diff) {
503
489
  const diff = data.diff;
504
490
  const callID = getCallID(context);
505
491
  if (callID) {
@@ -522,7 +508,19 @@ function createEditTool(ctx) {
522
508
  });
523
509
  }
524
510
  }
525
- return JSON.stringify(data);
511
+ let result = JSON.stringify(data);
512
+ // Append inline diagnostics to output (matching write tool pattern)
513
+ if (!args.dryRun) {
514
+ const diags = data.lsp_diagnostics;
515
+ if (diags && diags.length > 0) {
516
+ const errors = diags.filter((d) => d.severity === "error");
517
+ if (errors.length > 0) {
518
+ const diagLines = errors.map((d) => ` Line ${d.line}: ${d.message}`).join("\n");
519
+ result += `\n\nLSP errors detected, please fix:\n${diagLines}`;
520
+ }
521
+ }
522
+ }
523
+ return result;
526
524
  },
527
525
  };
528
526
  }
@@ -544,6 +542,11 @@ Uses the opencode patch format with \`*** Begin Patch\` / \`*** End Patch\` mark
544
542
  -old line to remove
545
543
  +new line to add
546
544
  context line (unchanged, prefixed with space)
545
+ *** Update File: path/to/old-name.ts
546
+ *** Move to: path/to/new-name.ts
547
+ @@ import { foo }
548
+ -import { foo } from './old'
549
+ +import { foo } from './new'
547
550
  *** Delete File: path/to/obsolete-file.ts
548
551
  *** End Patch
549
552
  \`\`\`
@@ -560,23 +563,24 @@ Uses the opencode patch format with \`*** Begin Patch\` / \`*** End Patch\` mark
560
563
  - \`+line\` — Add this line
561
564
  - \` line\` — Context line (space prefix), appears in both old and new
562
565
 
563
- **Parameters:**
564
- - \`patch\` (string, required): The full patch text including Begin/End markers
565
-
566
566
  **Behavior:**
567
- - All file changes are applied atomically — if any file fails, all changes are rolled back
567
+ - All file changes are applied with checkpoint-based rollback — if any file fails, previous changes are rolled back (best-effort)
568
568
  - Files are backed up before modification
569
569
  - Parent directories are created automatically for new files
570
- - Fuzzy matching for context anchors (handles whitespace and Unicode differences)`;
570
+ - Fuzzy matching for context anchors (handles whitespace and Unicode differences)
571
+
572
+ Returns: Status message string listing created, updated, moved, deleted, or failed file operations. May include inline LSP errors if type errors are introduced by the patch.`;
571
573
  function createApplyPatchTool(ctx) {
572
574
  return {
573
575
  description: APPLY_PATCH_DESCRIPTION,
574
576
  args: {
575
- patch: z.string(),
577
+ patchText: z.string().describe("The full patch text including Begin/End markers"),
576
578
  },
577
579
  execute: async (args, context) => {
578
580
  const bridge = ctx.pool.getBridge(context.directory);
579
- const patchText = args.patch;
581
+ const patchText = args.patchText;
582
+ if (!patchText)
583
+ throw new Error("'patchText' is required");
580
584
  // Parse the patch
581
585
  let hunks;
582
586
  try {
@@ -596,53 +600,138 @@ function createApplyPatchTool(ctx) {
596
600
  always: ["*"],
597
601
  metadata: {},
598
602
  });
599
- // Process each hunk
603
+ // Checkpoint all affected files for atomic rollback
604
+ const checkpointName = `apply_patch_${Date.now()}`;
605
+ try {
606
+ await bridge.send("checkpoint", {
607
+ name: checkpointName,
608
+ files: allPaths.map((p) => path.resolve(context.directory, p)),
609
+ });
610
+ }
611
+ catch {
612
+ // Checkpoint failure is non-fatal — proceed without rollback protection
613
+ }
614
+ // Process each hunk, track diffs for metadata
600
615
  const results = [];
616
+ let combinedBefore = "";
617
+ let combinedAfter = "";
618
+ let patchFailed = false;
601
619
  for (const hunk of hunks) {
602
620
  const filePath = path.resolve(context.directory, hunk.path);
603
621
  switch (hunk.type) {
604
622
  case "add": {
605
- await bridge.send("write", {
606
- file: filePath,
607
- content: hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`,
608
- create_dirs: true,
609
- });
610
- results.push(`Created ${hunk.path}`);
623
+ try {
624
+ await bridge.send("write", {
625
+ file: filePath,
626
+ content: hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`,
627
+ create_dirs: true,
628
+ diagnostics: true,
629
+ });
630
+ combinedAfter += hunk.contents;
631
+ results.push(`Created ${hunk.path}`);
632
+ }
633
+ catch (e) {
634
+ patchFailed = true;
635
+ results.push(`Failed to create ${hunk.path}: ${e instanceof Error ? e.message : e}`);
636
+ }
611
637
  break;
612
638
  }
613
639
  case "delete": {
614
640
  try {
615
- await fs.promises.unlink(filePath);
641
+ const before = await fs.promises.readFile(filePath, "utf-8").catch(() => "");
642
+ await bridge.send("delete_file", { file: filePath });
643
+ combinedBefore += before;
616
644
  results.push(`Deleted ${hunk.path}`);
617
645
  }
618
646
  catch (e) {
647
+ patchFailed = true;
619
648
  results.push(`Failed to delete ${hunk.path}: ${e instanceof Error ? e.message : e}`);
620
649
  }
621
650
  break;
622
651
  }
623
652
  case "update": {
624
- // Read original, apply chunks, write back
625
- const original = await fs.promises.readFile(filePath, "utf-8");
626
- const newContent = applyUpdateChunks(original, filePath, hunk.chunks);
627
- const targetPath = hunk.move_path
628
- ? path.resolve(context.directory, hunk.move_path)
629
- : filePath;
630
- await bridge.send("write", {
631
- file: targetPath,
632
- content: newContent,
633
- create_dirs: true,
634
- });
635
- if (hunk.move_path) {
636
- await fs.promises.unlink(filePath);
637
- results.push(`Updated and moved ${hunk.path} ${hunk.move_path}`);
653
+ try {
654
+ // Read original, apply chunks, write back
655
+ const original = await fs.promises.readFile(filePath, "utf-8");
656
+ const newContent = applyUpdateChunks(original, filePath, hunk.chunks);
657
+ const targetPath = hunk.move_path
658
+ ? path.resolve(context.directory, hunk.move_path)
659
+ : filePath;
660
+ const writeResult = await bridge.send("write", {
661
+ file: targetPath,
662
+ content: newContent,
663
+ create_dirs: true,
664
+ diagnostics: true,
665
+ });
666
+ // Collect diagnostics from this file
667
+ const diags = writeResult.lsp_diagnostics;
668
+ if (diags && diags.length > 0) {
669
+ const errors = diags.filter((d) => d.severity === "error");
670
+ if (errors.length > 0) {
671
+ const relPath = path.relative(context.worktree, targetPath);
672
+ const diagLines = errors.map((d) => ` Line ${d.line}: ${d.message}`).join("\n");
673
+ results.push(`\nLSP errors detected in ${relPath}, please fix:\n${diagLines}`);
674
+ }
675
+ }
676
+ // Track diff for metadata
677
+ combinedBefore += original;
678
+ combinedAfter += newContent;
679
+ if (hunk.move_path) {
680
+ await bridge.send("delete_file", { file: filePath });
681
+ results.push(`Updated and moved ${hunk.path} → ${hunk.move_path}`);
682
+ }
683
+ else {
684
+ results.push(`Updated ${hunk.path}`);
685
+ }
638
686
  }
639
- else {
640
- results.push(`Updated ${hunk.path}`);
687
+ catch (e) {
688
+ patchFailed = true;
689
+ results.push(`Failed to update ${hunk.path}: ${e instanceof Error ? e.message : e}`);
690
+ break;
641
691
  }
642
692
  break;
643
693
  }
644
694
  }
645
695
  }
696
+ // On failure, restore checkpoint to undo partial changes
697
+ if (patchFailed) {
698
+ try {
699
+ await bridge.send("restore_checkpoint", { name: checkpointName });
700
+ results.push("Patch failed — restored files to pre-patch state.");
701
+ }
702
+ catch {
703
+ results.push("Patch failed — checkpoint restore also failed, files may be inconsistent.");
704
+ }
705
+ return results.join("\n");
706
+ }
707
+ // Store metadata for tool.execute.after hook (match opencode built-in format)
708
+ const callID = getCallID(context);
709
+ if (callID) {
710
+ // Build per-file metadata matching opencode's files array
711
+ const files = hunks.map((h) => {
712
+ const relPath = path.relative(context.worktree, path.resolve(context.directory, h.path));
713
+ return {
714
+ filePath: path.resolve(context.directory, h.path),
715
+ relativePath: relPath,
716
+ type: h.type,
717
+ };
718
+ });
719
+ // Build title matching built-in: "Success. Updated the following files:\nM path/to/file.ts"
720
+ const fileList = files
721
+ .map((f) => {
722
+ const prefix = f.type === "add" ? "A" : f.type === "delete" ? "D" : "M";
723
+ return `${prefix} ${f.relativePath}`;
724
+ })
725
+ .join("\n");
726
+ const title = `Success. Updated the following files:\n${fileList}`;
727
+ storeToolMetadata(context.sessionID, callID, {
728
+ title,
729
+ metadata: {
730
+ diff: buildUnifiedDiff(files.length === 1 ? files[0].filePath : "patch", combinedBefore, combinedAfter),
731
+ files,
732
+ },
733
+ });
734
+ }
646
735
  return results.join("\n");
647
736
  },
648
737
  };
@@ -651,21 +740,21 @@ function createApplyPatchTool(ctx) {
651
740
  // Delete
652
741
  // ---------------------------------------------------------------------------
653
742
  const DELETE_DESCRIPTION = "Delete a file with backup (recoverable via aft_safety undo).\n\n" +
654
- "Parameters:\n" +
655
- "- file (string, required): Path to file to delete. Relative paths resolved from project root.\n\n" +
656
743
  "Returns: { file, deleted, backup_id } on success.\n" +
657
744
  "The file content is backed up before deletion — use aft_safety undo to recover if needed.";
658
745
  function createDeleteTool(ctx) {
659
746
  return {
660
747
  description: DELETE_DESCRIPTION,
661
748
  args: {
662
- file: z.string(),
749
+ filePath: z
750
+ .string()
751
+ .describe("Path to file to delete (absolute or relative to project root)"),
663
752
  },
664
753
  execute: async (args, context) => {
665
754
  const bridge = ctx.pool.getBridge(context.directory);
666
- const filePath = path.isAbsolute(args.file)
667
- ? args.file
668
- : path.resolve(context.directory, args.file);
755
+ const filePath = path.isAbsolute(args.filePath)
756
+ ? args.filePath
757
+ : path.resolve(context.directory, args.filePath);
669
758
  await context.ask({
670
759
  permission: "edit",
671
760
  patterns: [filePath],
@@ -681,24 +770,26 @@ function createDeleteTool(ctx) {
681
770
  // Move / Rename
682
771
  // ---------------------------------------------------------------------------
683
772
  const MOVE_DESCRIPTION = "Move or rename a file with backup (recoverable via aft_safety undo).\n\n" +
684
- "Parameters:\n" +
685
- "- file (string, required): Source file path to move.\n" +
686
- "- destination (string, required): Destination file path.\n\n" +
687
773
  "Creates parent directories for destination automatically.\n" +
688
774
  "Falls back to copy+delete for cross-filesystem moves.\n" +
689
- "Returns: { file, destination, moved, backup_id } on success.";
775
+ "Returns: { file, destination, moved, backup_id } on success.\n\n" +
776
+ "Note: This moves/renames files at the OS level. To move a code symbol (function, class) to another file while updating all imports, use aft_refactor with op='move' instead.";
690
777
  function createMoveTool(ctx) {
691
778
  return {
692
779
  description: MOVE_DESCRIPTION,
693
780
  args: {
694
- file: z.string(),
695
- destination: z.string(),
781
+ filePath: z
782
+ .string()
783
+ .describe("Source file path to move (absolute or relative to project root)"),
784
+ destination: z
785
+ .string()
786
+ .describe("Destination file path (absolute or relative to project root)"),
696
787
  },
697
788
  execute: async (args, context) => {
698
789
  const bridge = ctx.pool.getBridge(context.directory);
699
- const filePath = path.isAbsolute(args.file)
700
- ? args.file
701
- : path.resolve(context.directory, args.file);
790
+ const filePath = path.isAbsolute(args.filePath)
791
+ ? args.filePath
792
+ : path.resolve(context.directory, args.filePath);
702
793
  const destPath = path.isAbsolute(args.destination)
703
794
  ? args.destination
704
795
  : path.resolve(context.directory, args.destination);
@@ -726,8 +817,8 @@ function createMoveTool(ctx) {
726
817
  export function hoistedTools(ctx) {
727
818
  return {
728
819
  read: createReadTool(ctx),
729
- write: createWriteTool(ctx),
730
- edit: createEditTool(ctx),
820
+ write: createWriteTool(ctx, "edit"),
821
+ edit: createEditTool(ctx, "write"),
731
822
  apply_patch: createApplyPatchTool(ctx),
732
823
  aft_delete: createDeleteTool(ctx),
733
824
  aft_move: createMoveTool(ctx),
@@ -737,10 +828,22 @@ export function hoistedTools(ctx) {
737
828
  * Returns the same tools with aft_ prefix (for when hoisting is disabled).
738
829
  */
739
830
  export function aftPrefixedTools(ctx) {
831
+ const aftEditTool = createEditTool(ctx, "aft_write");
740
832
  return {
741
833
  aft_read: createReadTool(ctx),
742
- aft_write: createWriteTool(ctx),
743
- aft_edit: createEditTool(ctx),
834
+ aft_write: createWriteTool(ctx, "aft_edit"),
835
+ aft_edit: {
836
+ ...aftEditTool,
837
+ execute: async (args, context) => {
838
+ const argRecord = args;
839
+ const normalizedArgs = argRecord.mode !== undefined &&
840
+ argRecord.filePath === undefined &&
841
+ typeof argRecord.file === "string"
842
+ ? { ...argRecord, filePath: argRecord.file }
843
+ : argRecord;
844
+ return aftEditTool.execute(normalizedArgs, context);
845
+ },
846
+ },
744
847
  aft_apply_patch: createApplyPatchTool(ctx),
745
848
  aft_delete: createDeleteTool(ctx),
746
849
  aft_move: createMoveTool(ctx),