@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +22 -24
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +604 -578
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +73 -44
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -345
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import { StringEnum } from "@oh-my-pi/pi-ai";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
+ import type { Static } from "@sinclair/typebox";
6
7
  import { Type } from "@sinclair/typebox";
7
8
  import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
8
9
  import findDescription from "../../prompts/tools/find.md" with { type: "text" };
@@ -109,264 +110,68 @@ async function captureCommandOutput(
109
110
  return { stdout, stderr, exitCode, aborted: scope.aborted };
110
111
  }
111
112
 
112
- export function createFindTool(session: ToolSession, options?: FindToolOptions): AgentTool<typeof findSchema> {
113
- const customOps = options?.operations;
114
-
115
- return {
116
- name: "find",
117
- label: "Find",
118
- description: renderPromptTemplate(findDescription),
119
- parameters: findSchema,
120
- execute: async (
121
- _toolCallId: string,
122
- {
123
- pattern,
124
- path: searchDir,
125
- limit,
126
- hidden,
127
- sortByMtime,
128
- type,
129
- }: {
130
- pattern: string;
131
- path?: string;
132
- limit?: number;
133
- hidden?: boolean;
134
- sortByMtime?: boolean;
135
- type?: "file" | "dir" | "all";
136
- },
137
- signal?: AbortSignal,
138
- ) => {
139
- return untilAborted(signal, async () => {
140
- const searchPath = resolveToCwd(searchDir || ".", session.cwd);
141
- const scopePath = (() => {
142
- const relative = path.relative(session.cwd, searchPath).replace(/\\/g, "/");
143
- return relative.length === 0 ? "." : relative;
144
- })();
145
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
146
- const effectiveType = type ?? "all";
147
- const includeHidden = hidden ?? true;
148
- const shouldSortByMtime = sortByMtime ?? false;
149
-
150
- // If custom operations provided with glob, use that instead of fd
151
- if (customOps?.glob) {
152
- if (!(await customOps.exists(searchPath))) {
153
- throw new Error(`Path not found: ${searchPath}`);
154
- }
155
-
156
- const results = await customOps.glob(pattern, searchPath, {
157
- ignore: ["**/node_modules/**", "**/.git/**"],
158
- limit: effectiveLimit,
159
- });
160
-
161
- if (results.length === 0) {
162
- return {
163
- content: [{ type: "text", text: "No files found matching pattern" }],
164
- details: { scopePath, fileCount: 0, files: [], truncated: false },
165
- };
166
- }
167
-
168
- // Relativize paths
169
- const relativized = results.map((p) => {
170
- if (p.startsWith(searchPath)) {
171
- return p.slice(searchPath.length + 1);
172
- }
173
- return path.relative(searchPath, p);
174
- });
175
-
176
- const resultLimitReached = relativized.length >= effectiveLimit;
177
- const rawOutput = relativized.join("\n");
178
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
179
-
180
- let resultOutput = truncation.content;
181
- const details: FindToolDetails = {
182
- scopePath,
183
- fileCount: relativized.length,
184
- files: relativized,
185
- truncated: resultLimitReached || truncation.truncated,
186
- };
187
- const notices: string[] = [];
188
-
189
- if (resultLimitReached) {
190
- notices.push(
191
- `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
192
- );
193
- details.resultLimitReached = effectiveLimit;
194
- }
195
-
196
- if (truncation.truncated) {
197
- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
198
- details.truncation = truncation;
199
- }
200
-
201
- if (notices.length > 0) {
202
- resultOutput += `\n\n[${notices.join(". ")}]`;
203
- }
204
-
205
- return {
206
- content: [{ type: "text", text: resultOutput }],
207
- details: Object.keys(details).length > 0 ? details : undefined,
208
- };
209
- }
210
-
211
- // Default: use fd
212
- const fdPath = await ensureTool("fd", true);
213
- if (!fdPath) {
214
- throw new Error("fd is not available and could not be downloaded");
215
- }
216
-
217
- // Build fd arguments
218
- // When pattern contains path separators (e.g. "reports/**"), use --full-path
219
- // so fd matches against the full path, not just the filename.
220
- // Also prepend **/ to anchor the pattern at any depth in the search path.
221
- // Note: "**/foo.rs" is a glob construct (filename at any depth), not a path.
222
- // Only patterns with real path components like "foo/bar" or "foo/**/bar" need --full-path.
223
- const patternWithoutLeadingStarStar = pattern.replace(/^\*\*\//, "");
224
- const hasPathSeparator =
225
- patternWithoutLeadingStarStar.includes("/") || patternWithoutLeadingStarStar.includes("\\");
226
- const effectivePattern = hasPathSeparator && !pattern.startsWith("**/") ? `**/${pattern}` : pattern;
227
- const args: string[] = [
228
- "--glob", // Use glob pattern
229
- ...(hasPathSeparator ? ["--full-path"] : []),
230
- "--color=never", // No ANSI colors
231
- "--max-results",
232
- String(effectiveLimit),
233
- ];
234
-
235
- if (includeHidden) {
236
- args.push("--hidden");
237
- }
238
-
239
- // Add type filter
240
- if (effectiveType === "file") {
241
- args.push("--type", "f");
242
- } else if (effectiveType === "dir") {
243
- args.push("--type", "d");
244
- }
245
-
246
- // Include .gitignore files (root + nested) so fd respects them even outside git repos
247
- const gitignoreFiles = new Set<string>();
248
- const rootGitignore = path.join(searchPath, ".gitignore");
249
- if (await Bun.file(rootGitignore).exists()) {
250
- gitignoreFiles.add(rootGitignore);
251
- }
252
-
253
- try {
254
- const gitignoreArgs = [
255
- "--hidden",
256
- "--no-ignore",
257
- "--type",
258
- "f",
259
- "--name",
260
- ".gitignore",
261
- "--exclude",
262
- ".git",
263
- "--exclude",
264
- "node_modules",
265
- "--absolute-path",
266
- searchPath,
267
- ];
268
- const { stdout: gitignoreStdout, aborted: gitignoreAborted } = await captureCommandOutput(
269
- fdPath,
270
- gitignoreArgs,
271
- signal,
272
- );
273
- if (gitignoreAborted) {
274
- throw new Error("Operation aborted");
275
- }
276
- for (const rawLine of gitignoreStdout.split("\n")) {
277
- const file = rawLine.trim();
278
- if (!file) continue;
279
- gitignoreFiles.add(file);
280
- }
281
- } catch (err) {
282
- if (signal?.aborted) {
283
- throw err instanceof Error ? err : new Error("Operation aborted");
284
- }
285
- // Ignore lookup errors
286
- }
287
-
288
- for (const gitignorePath of gitignoreFiles) {
289
- args.push("--ignore-file", gitignorePath);
290
- }
291
-
292
- // Pattern and path
293
- args.push(effectivePattern, searchPath);
294
-
295
- // Run fd
296
- const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal);
297
-
298
- if (aborted) {
299
- throw new Error("Operation aborted");
113
+ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
114
+ public readonly name = "find";
115
+ public readonly label = "Find";
116
+ public readonly description: string;
117
+ public readonly parameters = findSchema;
118
+
119
+ private readonly session: ToolSession;
120
+ private readonly customOps?: FindOperations;
121
+
122
+ constructor(session: ToolSession, options?: FindToolOptions) {
123
+ this.session = session;
124
+ this.customOps = options?.operations;
125
+ this.description = renderPromptTemplate(findDescription);
126
+ }
127
+
128
+ public async execute(
129
+ _toolCallId: string,
130
+ params: Static<typeof findSchema>,
131
+ signal?: AbortSignal,
132
+ _onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
133
+ _context?: AgentToolContext,
134
+ ): Promise<AgentToolResult<FindToolDetails>> {
135
+ const { pattern, path: searchDir, limit, hidden, sortByMtime, type } = params;
136
+
137
+ return untilAborted(signal, async () => {
138
+ const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
139
+ const scopePath = (() => {
140
+ const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
141
+ return relative.length === 0 ? "." : relative;
142
+ })();
143
+ const effectiveLimit = limit ?? DEFAULT_LIMIT;
144
+ const effectiveType = type ?? "all";
145
+ const includeHidden = hidden ?? true;
146
+ const shouldSortByMtime = sortByMtime ?? false;
147
+
148
+ // If custom operations provided with glob, use that instead of fd
149
+ if (this.customOps?.glob) {
150
+ if (!(await this.customOps.exists(searchPath))) {
151
+ throw new Error(`Path not found: ${searchPath}`);
300
152
  }
301
153
 
302
- const output = stdout.trim();
303
-
304
- if (exitCode !== 0) {
305
- const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`;
306
- // fd returns non-zero for some errors but may still have partial output
307
- if (!output) {
308
- throw new Error(errorMsg);
309
- }
310
- }
154
+ const results = await this.customOps.glob(pattern, searchPath, {
155
+ ignore: ["**/node_modules/**", "**/.git/**"],
156
+ limit: effectiveLimit,
157
+ });
311
158
 
312
- if (!output) {
159
+ if (results.length === 0) {
313
160
  return {
314
161
  content: [{ type: "text", text: "No files found matching pattern" }],
315
162
  details: { scopePath, fileCount: 0, files: [], truncated: false },
316
163
  };
317
164
  }
318
165
 
319
- const lines = output.split("\n");
320
- const relativized: string[] = [];
321
- const mtimes: number[] = [];
322
-
323
- for (const rawLine of lines) {
324
- signal?.throwIfAborted();
325
- const line = rawLine.replace(/\r$/, "").trim();
326
- if (!line) {
327
- continue;
328
- }
329
-
330
- const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
331
- let relativePath = line;
332
- if (line.startsWith(searchPath)) {
333
- relativePath = line.slice(searchPath.length + 1); // +1 for the /
334
- } else {
335
- relativePath = path.relative(searchPath, line);
166
+ // Relativize paths
167
+ const relativized = results.map((p) => {
168
+ if (p.startsWith(searchPath)) {
169
+ return p.slice(searchPath.length + 1);
336
170
  }
171
+ return path.relative(searchPath, p);
172
+ });
337
173
 
338
- if (hadTrailingSlash && !relativePath.endsWith("/")) {
339
- relativePath += "/";
340
- }
341
-
342
- // When sorting by mtime, keep files that fail to stat with mtime 0
343
- if (shouldSortByMtime) {
344
- try {
345
- const fullPath = path.join(searchPath, relativePath);
346
- const stat = await Bun.file(fullPath).stat();
347
- relativized.push(relativePath);
348
- mtimes.push(stat.mtimeMs);
349
- } catch {
350
- relativized.push(relativePath);
351
- mtimes.push(0);
352
- }
353
- } else {
354
- relativized.push(relativePath);
355
- }
356
- }
357
-
358
- // Sort by mtime if requested (most recent first)
359
- if (shouldSortByMtime && relativized.length > 0) {
360
- const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] }));
361
- indexed.sort((a, b) => b.mtime - a.mtime);
362
- relativized.length = 0;
363
- relativized.push(...indexed.map((item) => item.path));
364
- }
365
-
366
- // Check if we hit the result limit
367
174
  const resultLimitReached = relativized.length >= effectiveLimit;
368
-
369
- // Apply byte truncation (no line limit since we already have result limit)
370
175
  const rawOutput = relativized.join("\n");
371
176
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
372
177
 
@@ -377,8 +182,6 @@ export function createFindTool(session: ToolSession, options?: FindToolOptions):
377
182
  files: relativized,
378
183
  truncated: resultLimitReached || truncation.truncated,
379
184
  };
380
-
381
- // Build notices
382
185
  const notices: string[] = [];
383
186
 
384
187
  if (resultLimitReached) {
@@ -399,11 +202,205 @@ export function createFindTool(session: ToolSession, options?: FindToolOptions):
399
202
 
400
203
  return {
401
204
  content: [{ type: "text", text: resultOutput }],
402
- details: Object.keys(details).length > 0 ? details : undefined,
205
+ details,
206
+ };
207
+ }
208
+
209
+ // Default: use fd
210
+ const fdPath = await ensureTool("fd", true);
211
+ if (!fdPath) {
212
+ throw new Error("fd is not available and could not be downloaded");
213
+ }
214
+
215
+ // Build fd arguments
216
+ // When pattern contains path separators (e.g. "reports/**"), use --full-path
217
+ // so fd matches against the full path, not just the filename.
218
+ // Also prepend **/ to anchor the pattern at any depth in the search path.
219
+ // Note: "**/foo.rs" is a glob construct (filename at any depth), not a path.
220
+ // Only patterns with real path components like "foo/bar" or "foo/**/bar" need --full-path.
221
+ const patternWithoutLeadingStarStar = pattern.replace(/^\*\*\//, "");
222
+ const hasPathSeparator =
223
+ patternWithoutLeadingStarStar.includes("/") || patternWithoutLeadingStarStar.includes("\\");
224
+ const effectivePattern = hasPathSeparator && !pattern.startsWith("**/") ? `**/${pattern}` : pattern;
225
+ const args: string[] = [
226
+ "--glob", // Use glob pattern
227
+ ...(hasPathSeparator ? ["--full-path"] : []),
228
+ "--color=never", // No ANSI colors
229
+ "--max-results",
230
+ String(effectiveLimit),
231
+ ];
232
+
233
+ if (includeHidden) {
234
+ args.push("--hidden");
235
+ }
236
+
237
+ // Add type filter
238
+ if (effectiveType === "file") {
239
+ args.push("--type", "f");
240
+ } else if (effectiveType === "dir") {
241
+ args.push("--type", "d");
242
+ }
243
+
244
+ // Include .gitignore files (root + nested) so fd respects them even outside git repos
245
+ const gitignoreFiles = new Set<string>();
246
+ const rootGitignore = path.join(searchPath, ".gitignore");
247
+ if (await Bun.file(rootGitignore).exists()) {
248
+ gitignoreFiles.add(rootGitignore);
249
+ }
250
+
251
+ try {
252
+ const gitignoreArgs = [
253
+ "--hidden",
254
+ "--no-ignore",
255
+ "--type",
256
+ "f",
257
+ "--name",
258
+ ".gitignore",
259
+ "--exclude",
260
+ ".git",
261
+ "--exclude",
262
+ "node_modules",
263
+ "--absolute-path",
264
+ searchPath,
265
+ ];
266
+ const { stdout: gitignoreStdout, aborted: gitignoreAborted } = await captureCommandOutput(
267
+ fdPath,
268
+ gitignoreArgs,
269
+ signal,
270
+ );
271
+ if (gitignoreAborted) {
272
+ throw new Error("Operation aborted");
273
+ }
274
+ for (const rawLine of gitignoreStdout.split("\n")) {
275
+ const file = rawLine.trim();
276
+ if (!file) continue;
277
+ gitignoreFiles.add(file);
278
+ }
279
+ } catch (err) {
280
+ if (signal?.aborted) {
281
+ throw err instanceof Error ? err : new Error("Operation aborted");
282
+ }
283
+ // Ignore lookup errors
284
+ }
285
+
286
+ for (const gitignorePath of gitignoreFiles) {
287
+ args.push("--ignore-file", gitignorePath);
288
+ }
289
+
290
+ // Pattern and path
291
+ args.push(effectivePattern, searchPath);
292
+
293
+ // Run fd
294
+ const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal);
295
+
296
+ if (aborted) {
297
+ throw new Error("Operation aborted");
298
+ }
299
+
300
+ const output = stdout.trim();
301
+
302
+ if (exitCode !== 0) {
303
+ const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`;
304
+ // fd returns non-zero for some errors but may still have partial output
305
+ if (!output) {
306
+ throw new Error(errorMsg);
307
+ }
308
+ }
309
+
310
+ if (!output) {
311
+ return {
312
+ content: [{ type: "text", text: "No files found matching pattern" }],
313
+ details: { scopePath, fileCount: 0, files: [], truncated: false },
403
314
  };
404
- });
405
- },
406
- };
315
+ }
316
+
317
+ const lines = output.split("\n");
318
+ const relativized: string[] = [];
319
+ const mtimes: number[] = [];
320
+
321
+ for (const rawLine of lines) {
322
+ signal?.throwIfAborted();
323
+ const line = rawLine.replace(/\r$/, "").trim();
324
+ if (!line) {
325
+ continue;
326
+ }
327
+
328
+ const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
329
+ let relativePath = line;
330
+ if (line.startsWith(searchPath)) {
331
+ relativePath = line.slice(searchPath.length + 1); // +1 for the /
332
+ } else {
333
+ relativePath = path.relative(searchPath, line);
334
+ }
335
+
336
+ if (hadTrailingSlash && !relativePath.endsWith("/")) {
337
+ relativePath += "/";
338
+ }
339
+
340
+ // When sorting by mtime, keep files that fail to stat with mtime 0
341
+ if (shouldSortByMtime) {
342
+ try {
343
+ const fullPath = path.join(searchPath, relativePath);
344
+ const stat = await Bun.file(fullPath).stat();
345
+ relativized.push(relativePath);
346
+ mtimes.push(stat.mtimeMs);
347
+ } catch {
348
+ relativized.push(relativePath);
349
+ mtimes.push(0);
350
+ }
351
+ } else {
352
+ relativized.push(relativePath);
353
+ }
354
+ }
355
+
356
+ // Sort by mtime if requested (most recent first)
357
+ if (shouldSortByMtime && relativized.length > 0) {
358
+ const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] }));
359
+ indexed.sort((a, b) => b.mtime - a.mtime);
360
+ relativized.length = 0;
361
+ relativized.push(...indexed.map((item) => item.path));
362
+ }
363
+
364
+ // Check if we hit the result limit
365
+ const resultLimitReached = relativized.length >= effectiveLimit;
366
+
367
+ // Apply byte truncation (no line limit since we already have result limit)
368
+ const rawOutput = relativized.join("\n");
369
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
370
+
371
+ let resultOutput = truncation.content;
372
+ const details: FindToolDetails = {
373
+ scopePath,
374
+ fileCount: relativized.length,
375
+ files: relativized,
376
+ truncated: resultLimitReached || truncation.truncated,
377
+ };
378
+
379
+ // Build notices
380
+ const notices: string[] = [];
381
+
382
+ if (resultLimitReached) {
383
+ notices.push(
384
+ `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
385
+ );
386
+ details.resultLimitReached = effectiveLimit;
387
+ }
388
+
389
+ if (truncation.truncated) {
390
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
391
+ details.truncation = truncation;
392
+ }
393
+
394
+ if (notices.length > 0) {
395
+ resultOutput += `\n\n[${notices.join(". ")}]`;
396
+ }
397
+
398
+ return {
399
+ content: [{ type: "text", text: resultOutput }],
400
+ details,
401
+ };
402
+ });
403
+ }
407
404
  }
408
405
 
409
406
  // =============================================================================
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import { StringEnum } from "@oh-my-pi/pi-ai";
3
3
  import { type GitParams, gitTool as gitToolCore, type ToolResponse } from "@oh-my-pi/pi-git-tool";
4
4
  import { type Static, Type } from "@sinclair/typebox";
@@ -171,37 +171,43 @@ const gitSchema = Type.Object({
171
171
 
172
172
  export type GitToolDetails = ToolResponse<unknown>;
173
173
 
174
- export function createGitTool(session: ToolSession): AgentTool<typeof gitSchema, GitToolDetails> | null {
175
- if (session.settings?.getGitToolEnabled() === false) {
176
- return null;
174
+ export class GitTool implements AgentTool<typeof gitSchema, GitToolDetails> {
175
+ public readonly name = "git";
176
+ public readonly label = "Git";
177
+ public readonly description: string;
178
+ public readonly parameters = gitSchema;
179
+
180
+ private readonly session: ToolSession;
181
+
182
+ constructor(session: ToolSession) {
183
+ this.session = session;
184
+ this.description = renderPromptTemplate(gitDescription);
177
185
  }
178
- return {
179
- name: "git",
180
- label: "Git",
181
- description: renderPromptTemplate(gitDescription),
182
- parameters: gitSchema,
183
- execute: async (_toolCallId, params: Static<typeof gitSchema>, _signal?: AbortSignal) => {
184
- if (params.operation === "commit" && !params.message) {
185
- throw new Error("Git commit requires a message to avoid an interactive editor. Provide `message`.");
186
- }
187
-
188
- const result = await gitToolCore(params as GitParams, session.cwd);
189
- if ("error" in result) {
190
- const message = result._rendered ?? result.error;
191
- return { content: [{ type: "text", text: message }], details: result };
192
- }
193
- if ("confirm" in result) {
194
- const message = result._rendered ?? result.confirm;
195
- return { content: [{ type: "text", text: message }], details: result };
196
- }
197
- return { content: [{ type: "text", text: result._rendered }], details: result };
198
- },
199
- };
200
- }
201
186
 
202
- export const gitTool = createGitTool({
203
- cwd: process.cwd(),
204
- hasUI: false,
205
- getSessionFile: () => null,
206
- getSessionSpawns: () => null,
207
- })!;
187
+ static createIf(session: ToolSession): GitTool | null {
188
+ return session.settings?.getGitToolEnabled() === false ? null : new GitTool(session);
189
+ }
190
+
191
+ public async execute(
192
+ _toolCallId: string,
193
+ params: Static<typeof gitSchema>,
194
+ _signal?: AbortSignal,
195
+ _onUpdate?: AgentToolUpdateCallback<GitToolDetails>,
196
+ _context?: AgentToolContext,
197
+ ): Promise<AgentToolResult<GitToolDetails>> {
198
+ if (params.operation === "commit" && !params.message) {
199
+ throw new Error("Git commit requires a message to avoid an interactive editor. Provide `message`.");
200
+ }
201
+
202
+ const result = await gitToolCore(params as GitParams, this.session.cwd);
203
+ if ("error" in result) {
204
+ const message = result._rendered ?? result.error;
205
+ return { content: [{ type: "text", text: message }], details: result };
206
+ }
207
+ if ("confirm" in result) {
208
+ const message = result._rendered ?? result.confirm;
209
+ return { content: [{ type: "text", text: message }], details: result };
210
+ }
211
+ return { content: [{ type: "text", text: result._rendered }], details: result };
212
+ }
213
+ }