@oh-my-pi/pi-coding-agent 14.5.2 → 14.5.5

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 (69) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/sdk/README.md +1 -1
  4. package/package.json +7 -7
  5. package/src/config/prompt-templates.ts +104 -6
  6. package/src/config/settings-schema.ts +14 -13
  7. package/src/config/settings.ts +1 -1
  8. package/src/cursor.ts +4 -4
  9. package/src/edit/index.ts +111 -109
  10. package/src/edit/line-hash.ts +33 -3
  11. package/src/edit/modes/apply-patch.ts +6 -4
  12. package/src/edit/modes/atom.lark +27 -0
  13. package/src/edit/modes/atom.ts +1094 -642
  14. package/src/edit/modes/hashline.ts +9 -10
  15. package/src/edit/modes/patch.ts +23 -19
  16. package/src/edit/modes/replace.ts +19 -15
  17. package/src/edit/renderer.ts +65 -8
  18. package/src/edit/streaming.ts +47 -77
  19. package/src/extensibility/extensions/types.ts +11 -11
  20. package/src/extensibility/hooks/types.ts +6 -6
  21. package/src/lsp/edits.ts +8 -5
  22. package/src/lsp/index.ts +4 -4
  23. package/src/lsp/utils.ts +13 -43
  24. package/src/mcp/discoverable-tool-metadata.ts +1 -1
  25. package/src/mcp/manager.ts +3 -3
  26. package/src/mcp/tool-bridge.ts +4 -4
  27. package/src/memories/index.ts +1 -1
  28. package/src/modes/acp/acp-event-mapper.ts +1 -1
  29. package/src/modes/components/session-observer-overlay.ts +1 -1
  30. package/src/modes/components/settings-defs.ts +3 -3
  31. package/src/modes/components/tree-selector.ts +2 -2
  32. package/src/modes/controllers/event-controller.ts +12 -0
  33. package/src/modes/utils/ui-helpers.ts +31 -7
  34. package/src/prompts/agents/explore.md +1 -1
  35. package/src/prompts/agents/librarian.md +2 -2
  36. package/src/prompts/agents/plan.md +2 -2
  37. package/src/prompts/agents/reviewer.md +1 -1
  38. package/src/prompts/agents/task.md +2 -2
  39. package/src/prompts/system/plan-mode-active.md +1 -1
  40. package/src/prompts/system/system-prompt.md +34 -31
  41. package/src/prompts/tools/apply-patch.md +0 -2
  42. package/src/prompts/tools/atom.md +88 -97
  43. package/src/prompts/tools/bash.md +7 -4
  44. package/src/prompts/tools/checkpoint.md +1 -1
  45. package/src/prompts/tools/find.md +6 -1
  46. package/src/prompts/tools/hashline.md +10 -11
  47. package/src/prompts/tools/patch.md +13 -13
  48. package/src/prompts/tools/read.md +5 -5
  49. package/src/prompts/tools/replace.md +3 -3
  50. package/src/prompts/tools/{grep.md → search.md} +4 -4
  51. package/src/sdk.ts +19 -9
  52. package/src/session/agent-session.ts +69 -1
  53. package/src/system-prompt.ts +15 -5
  54. package/src/task/executor.ts +5 -0
  55. package/src/task/index.ts +10 -1
  56. package/src/tools/ast-edit.ts +27 -50
  57. package/src/tools/ast-grep.ts +22 -48
  58. package/src/tools/bash.ts +1 -1
  59. package/src/tools/file-recorder.ts +6 -6
  60. package/src/tools/find.ts +11 -13
  61. package/src/tools/grouped-file-output.ts +96 -0
  62. package/src/tools/index.ts +7 -7
  63. package/src/tools/path-utils.ts +31 -4
  64. package/src/tools/read.ts +12 -6
  65. package/src/tools/renderers.ts +2 -2
  66. package/src/tools/{grep.ts → search.ts} +43 -86
  67. package/src/tools/todo-write.ts +0 -1
  68. package/src/tools/write.ts +8 -4
  69. package/src/web/search/index.ts +1 -1
@@ -384,6 +384,8 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {
384
384
  export interface SystemPromptToolMetadata {
385
385
  label: string;
386
386
  description: string;
387
+ /** Tool name the model sees on the provider wire. Defaults to the internal tool name. */
388
+ wireName?: string;
387
389
  }
388
390
 
389
391
  export function buildSystemPromptToolMetadata(
@@ -394,12 +396,16 @@ export function buildSystemPromptToolMetadata(
394
396
  Array.from(tools.entries(), ([name, tool]) => {
395
397
  const toolRecord = tool as AgentTool & { label?: string; description?: string };
396
398
  const override = overrides[name];
399
+ const wireName =
400
+ override?.wireName ??
401
+ (typeof toolRecord.customWireName === "string" ? toolRecord.customWireName : undefined);
397
402
  return [
398
403
  name,
399
404
  {
400
405
  label: override?.label ?? (typeof toolRecord.label === "string" ? toolRecord.label : ""),
401
406
  description:
402
407
  override?.description ?? (typeof toolRecord.description === "string" ? toolRecord.description : ""),
408
+ wireName,
403
409
  },
404
410
  ] as const;
405
411
  }),
@@ -570,14 +576,17 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
570
576
  }
571
577
  }
572
578
 
573
- // Build tool descriptions for system prompt rendering
579
+ // Build tool descriptions for system prompt rendering.
580
+ const toolPromptNames = new Map<string, string>(toolNames.map(name => [name, tools?.get(name)?.wireName ?? name]));
581
+ const toolRefs = Object.fromEntries(toolPromptNames.entries());
574
582
  const toolInfo = toolNames.map(name => ({
575
- name,
583
+ name: toolPromptNames.get(name) ?? name,
584
+ internalName: name,
576
585
  label: tools?.get(name)?.label ?? "",
577
586
  description: tools?.get(name)?.description ?? "",
578
587
  }));
579
588
 
580
- // Filter skills to only include those with read tool
589
+ // Filter skills to only include those with read tool.
581
590
  const hasRead = tools?.has("read");
582
591
  const filteredSkills = hasRead ? skills : [];
583
592
 
@@ -589,6 +598,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
589
598
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
590
599
 
591
600
  const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
601
+ const reportToolIssueToolName = toolPromptNames.get("report_tool_issue") ?? "report_tool_issue";
592
602
  const data = {
593
603
  systemPromptCustomization: effectiveSystemPromptCustomization,
594
604
  customPrompt: resolvedCustomPrompt,
@@ -596,6 +606,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
596
606
  tools: toolNames,
597
607
  toolInfo,
598
608
  repeatToolDescriptions,
609
+ toolRefs,
599
610
  environment,
600
611
  contextFiles,
601
612
  agentsMdSearch,
@@ -617,8 +628,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
617
628
 
618
629
  // When autoqa is active the report_tool_issue tool is in the tool set — nudge the agent.
619
630
  if (toolNames.includes("report_tool_issue")) {
620
- rendered +=
621
- "\n\n<critical>\nThe `report_tool_issue` tool is available for automated QA. If ANY tool you call returns output that is unexpected, incorrect, malformed, or otherwise inconsistent with what you anticipated given the tool's described behavior and your parameters, call `report_tool_issue` with the tool name and a concise description of the discrepancy. Do not hesitate to report — false positives are acceptable.\n</critical>";
631
+ rendered += `\n\n<critical>\nThe \`${reportToolIssueToolName}\` tool is available for automated QA. If ANY tool you call returns output that is unexpected, incorrect, malformed, or otherwise inconsistent with what you anticipated given the tool's described behavior and your parameters, call \`${reportToolIssueToolName}\` with the tool name and a concise description of the discrepancy. Do not hesitate to report — false positives are acceptable.\n</critical>`;
622
632
  }
623
633
 
624
634
  return rendered;
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Runs each subagent on the main thread and forwards AgentEvents for progress tracking.
5
5
  */
6
+
6
7
  import path from "node:path";
7
8
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
9
  import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
@@ -16,6 +17,7 @@ import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
16
17
  import type { CustomTool } from "../extensibility/custom-tools/types";
17
18
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
18
19
  import type { Skill } from "../extensibility/skills";
20
+ import type { LocalProtocolOptions } from "../internal-urls";
19
21
  import { callTool } from "../mcp/client";
20
22
  import type { MCPManager } from "../mcp/manager";
21
23
  import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
@@ -159,6 +161,8 @@ export interface ExecutorOptions {
159
161
  authStorage?: AuthStorage;
160
162
  modelRegistry?: ModelRegistry;
161
163
  settings?: Settings;
164
+ /** Override local:// protocol options so subagent shares parent's local:// root */
165
+ localProtocolOptions?: LocalProtocolOptions;
162
166
  }
163
167
 
164
168
  function parseStringifiedJson(value: unknown): unknown {
@@ -987,6 +991,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
987
991
  enableMCP,
988
992
  mcpManager: options.mcpManager,
989
993
  customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
994
+ localProtocolOptions: options.localProtocolOptions,
990
995
  });
991
996
 
992
997
  activeSession = session;
package/src/task/index.ts CHANGED
@@ -28,6 +28,7 @@ import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type:
28
28
  import { formatBytes, formatDuration } from "../tools/render-utils";
29
29
  // Import review tools for side effects (registers subagent tool handlers)
30
30
  import "../tools/review";
31
+ import type { LocalProtocolOptions } from "../internal-urls";
31
32
  import { generateCommitMessage } from "../utils/commit-message-generator";
32
33
  import * as git from "../utils/git";
33
34
  import { discoverAgents, getAgent } from "./discovery";
@@ -567,7 +568,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
567
568
  }
568
569
 
569
570
  const planModeState = this.session.getPlanModeState?.();
570
- const planModeTools = ["read", "grep", "find", "ls", "lsp", "web_search"];
571
+ const planModeTools = ["read", "search", "find", "lsp", "web_search"];
571
572
  const effectiveAgent: typeof agent = planModeState?.enabled
572
573
  ? {
573
574
  ...agent,
@@ -715,6 +716,12 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
715
716
  const tempArtifactsDir = artifactsDir ? null : path.join(os.tmpdir(), `omp-task-${Snowflake.next()}`);
716
717
  const effectiveArtifactsDir = artifactsDir || tempArtifactsDir!;
717
718
 
719
+ // Share the parent session's local:// root with subagents so they read/write the same scratch space
720
+ const localProtocolOptions: LocalProtocolOptions = {
721
+ getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
722
+ getSessionId: this.session.getSessionId ?? (() => null),
723
+ };
724
+
718
725
  // Initialize progress tracking
719
726
  const progressMap = new Map<number, AgentProgress>();
720
727
 
@@ -856,6 +863,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
856
863
  contextFiles,
857
864
  skills: availableSkills,
858
865
  promptTemplates,
866
+ localProtocolOptions,
859
867
  });
860
868
  }
861
869
 
@@ -909,6 +917,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
909
917
  contextFiles,
910
918
  skills: availableSkills,
911
919
  promptTemplates,
920
+ localProtocolOptions,
912
921
  });
913
922
  if (mergeMode === "branch" && result.exitCode === 0) {
914
923
  try {
@@ -1,4 +1,3 @@
1
- import * as path from "node:path";
2
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
2
  import { type AstReplaceChange, astEdit } from "@oh-my-pi/pi-natives";
4
3
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -13,8 +12,10 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
13
12
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
13
  import type { ToolSession } from ".";
15
14
  import { createFileRecorder, formatResultPath } from "./file-recorder";
15
+ import { formatGroupedFiles } from "./grouped-file-output";
16
16
  import type { OutputMeta } from "./output-meta";
17
17
  import {
18
+ formatPathRelativeToCwd,
18
19
  hasGlobPathChars,
19
20
  normalizePathLikeInput,
20
21
  parseSearchPath,
@@ -105,10 +106,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
105
106
  const normalizedRewrites = Object.fromEntries(ops);
106
107
  const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
107
108
 
108
- const formatScopePath = (targetPath: string): string => {
109
- const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
110
- return relative.length === 0 ? "." : relative;
111
- };
109
+ const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
112
110
  let searchPath: string | undefined;
113
111
  let scopePath: string | undefined;
114
112
  let globFilter: string | undefined;
@@ -163,7 +161,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
163
161
  });
164
162
 
165
163
  const dedupedParseErrors = dedupeParseErrors(result.parseErrors);
166
- const formatPath = (filePath: string): string => formatResultPath(filePath, isDirectory);
164
+ const formatPath = (filePath: string): string =>
165
+ formatResultPath(filePath, isDirectory, resolvedSearchPath, this.session.cwd);
167
166
 
168
167
  const { record: recordFile, list: fileList } = createFileRecorder();
169
168
  const fileReplacementCounts = new Map<string, number>();
@@ -204,7 +203,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
204
203
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
205
204
  const outputLines: string[] = [];
206
205
  const displayLines: string[] = [];
207
- const renderChangesForFile = (relativePath: string) => {
206
+ const renderChangesForFile = (relativePath: string): { model: string[]; display: string[] } => {
207
+ const modelOut: string[] = [];
208
+ const displayOut: string[] = [];
208
209
  const fileChanges = changesByFile.get(relativePath) ?? [];
209
210
  const lineNumberWidth = fileChanges.reduce(
210
211
  (width, change) => Math.max(width, String(change.startLine).length),
@@ -222,55 +223,31 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
222
223
  ? `${change.startLine}${computeLineHash(change.startLine, afterFirstLine)}`
223
224
  : `${change.startLine}:${change.startColumn}`;
224
225
  const lineSeparator = useHashLines ? HASHLINE_CONTENT_SEPARATOR : " ";
225
- outputLines.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
226
- outputLines.push(`+${afterRef}${lineSeparator}${afterLine}`);
227
- displayLines.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
228
- displayLines.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
226
+ modelOut.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
227
+ modelOut.push(`+${afterRef}${lineSeparator}${afterLine}`);
228
+ displayOut.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
229
+ displayOut.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
229
230
  }
231
+ return { model: modelOut, display: displayOut };
230
232
  };
231
233
 
232
234
  if (isDirectory) {
233
- const filesByDirectory = new Map<string, string[]>();
234
- for (const relativePath of fileList) {
235
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
236
- if (!filesByDirectory.has(directory)) {
237
- filesByDirectory.set(directory, []);
238
- }
239
- filesByDirectory.get(directory)!.push(relativePath);
240
- }
241
- for (const [directory, directoryFiles] of filesByDirectory) {
242
- if (directory === ".") {
243
- for (const relativePath of directoryFiles) {
244
- if (outputLines.length > 0) {
245
- outputLines.push("");
246
- displayLines.push("");
247
- }
248
- const count = fileReplacementCounts.get(relativePath) ?? 0;
249
- const header = `# ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
250
- outputLines.push(header);
251
- displayLines.push(header);
252
- renderChangesForFile(relativePath);
253
- }
254
- continue;
255
- }
256
- if (outputLines.length > 0) {
257
- outputLines.push("");
258
- displayLines.push("");
259
- }
260
- const dirHeader = `# ${directory}`;
261
- outputLines.push(dirHeader);
262
- displayLines.push(dirHeader);
263
- for (const relativePath of directoryFiles) {
264
- const count = fileReplacementCounts.get(relativePath) ?? 0;
265
- const fileHeader = `## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
266
- outputLines.push(fileHeader);
267
- displayLines.push(fileHeader);
268
- renderChangesForFile(relativePath);
269
- }
270
- }
235
+ const grouped = formatGroupedFiles(fileList, relativePath => {
236
+ const rendered = renderChangesForFile(relativePath);
237
+ const count = fileReplacementCounts.get(relativePath) ?? 0;
238
+ return {
239
+ headerSuffix: ` (${formatCount("replacement", count)})`,
240
+ modelLines: rendered.model,
241
+ displayLines: rendered.display,
242
+ };
243
+ });
244
+ outputLines.push(...grouped.model);
245
+ displayLines.push(...grouped.display);
271
246
  } else {
272
247
  for (const relativePath of fileList) {
273
- renderChangesForFile(relativePath);
248
+ const rendered = renderChangesForFile(relativePath);
249
+ outputLines.push(...rendered.model);
250
+ displayLines.push(...rendered.display);
274
251
  }
275
252
  }
276
253
 
@@ -1,4 +1,3 @@
1
- import * as path from "node:path";
2
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
2
  import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives";
4
3
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -12,9 +11,11 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
12
11
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
13
12
  import type { ToolSession } from ".";
14
13
  import { createFileRecorder, formatResultPath } from "./file-recorder";
14
+ import { formatGroupedFiles } from "./grouped-file-output";
15
15
  import { formatMatchLine } from "./match-line-format";
16
16
  import type { OutputMeta } from "./output-meta";
17
17
  import {
18
+ formatPathRelativeToCwd,
18
19
  hasGlobPathChars,
19
20
  normalizePathLikeInput,
20
21
  parseSearchPath,
@@ -86,10 +87,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
86
87
  if (!Number.isFinite(skip) || skip < 0) {
87
88
  throw new ToolError("skip must be a non-negative number");
88
89
  }
89
- const formatScopePath = (targetPath: string): string => {
90
- const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
91
- return relative.length === 0 ? "." : relative;
92
- };
90
+ const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
93
91
  let searchPath: string | undefined;
94
92
  let scopePath: string | undefined;
95
93
  let globFilter: string | undefined;
@@ -146,7 +144,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
146
144
  return parseError?.[1] ?? error;
147
145
  });
148
146
  const dedupedParseErrors = dedupeParseErrors(normalizedParseErrors);
149
- const formatPath = (filePath: string): string => formatResultPath(filePath, isDirectory);
147
+ const formatPath = (filePath: string): string =>
148
+ formatResultPath(filePath, isDirectory, resolvedSearchPath, this.session.cwd);
150
149
 
151
150
  const { record: recordFile, list: fileList } = createFileRecorder();
152
151
  const fileMatchCounts = new Map<string, number>();
@@ -184,7 +183,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
184
183
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
185
184
  const outputLines: string[] = [];
186
185
  const displayLines: string[] = [];
187
- const renderMatchesForFile = (relativePath: string) => {
186
+ const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
187
+ const modelOut: string[] = [];
188
+ const displayOut: string[] = [];
188
189
  const fileMatches = matchesByFile.get(relativePath) ?? [];
189
190
  const lineNumberWidth = fileMatches.reduce((width, match) => {
190
191
  const lineCount = match.text.split("\n").length;
@@ -197,61 +198,34 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
197
198
  const lineNumber = match.startLine + index;
198
199
  const isMatch = index === 0;
199
200
  const line = matchLines[index] ?? "";
200
- outputLines.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
201
- displayLines.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
201
+ modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
202
+ displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
202
203
  }
203
204
  if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
204
205
  const serializedMeta = Object.entries(match.metaVariables)
205
206
  .sort(([left], [right]) => left.localeCompare(right))
206
207
  .map(([key, value]) => `${key}=${value}`)
207
208
  .join(", ");
208
- outputLines.push(` meta: ${serializedMeta}`);
209
- displayLines.push(` meta: ${serializedMeta}`);
209
+ modelOut.push(` meta: ${serializedMeta}`);
210
+ displayOut.push(` meta: ${serializedMeta}`);
210
211
  }
211
212
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
212
213
  }
214
+ return { model: modelOut, display: displayOut };
213
215
  };
214
216
 
215
217
  if (isDirectory) {
216
- const filesByDirectory = new Map<string, string[]>();
217
- for (const relativePath of fileList) {
218
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
219
- if (!filesByDirectory.has(directory)) {
220
- filesByDirectory.set(directory, []);
221
- }
222
- filesByDirectory.get(directory)!.push(relativePath);
223
- }
224
- for (const [directory, directoryFiles] of filesByDirectory) {
225
- if (directory === ".") {
226
- for (const relativePath of directoryFiles) {
227
- if (outputLines.length > 0) {
228
- outputLines.push("");
229
- displayLines.push("");
230
- }
231
- const header = `# ${path.basename(relativePath)}`;
232
- outputLines.push(header);
233
- displayLines.push(header);
234
- renderMatchesForFile(relativePath);
235
- }
236
- continue;
237
- }
238
- if (outputLines.length > 0) {
239
- outputLines.push("");
240
- displayLines.push("");
241
- }
242
- const dirHeader = `# ${directory}`;
243
- outputLines.push(dirHeader);
244
- displayLines.push(dirHeader);
245
- for (const relativePath of directoryFiles) {
246
- const fileHeader = `## └─ ${path.basename(relativePath)}`;
247
- outputLines.push(fileHeader);
248
- displayLines.push(fileHeader);
249
- renderMatchesForFile(relativePath);
250
- }
251
- }
218
+ const grouped = formatGroupedFiles(fileList, relativePath => {
219
+ const rendered = renderMatchesForFile(relativePath);
220
+ return { modelLines: rendered.model, displayLines: rendered.display };
221
+ });
222
+ outputLines.push(...grouped.model);
223
+ displayLines.push(...grouped.display);
252
224
  } else {
253
225
  for (const relativePath of fileList) {
254
- renderMatchesForFile(relativePath);
226
+ const rendered = renderMatchesForFile(relativePath);
227
+ outputLines.push(...rendered.model);
228
+ displayLines.push(...rendered.display);
255
229
  }
256
230
  }
257
231
 
package/src/tools/bash.ts CHANGED
@@ -269,7 +269,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
269
269
  autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
270
270
  hasAstGrep: this.session.settings.get("astGrep.enabled"),
271
271
  hasAstEdit: this.session.settings.get("astEdit.enabled"),
272
- hasGrep: this.session.settings.get("grep.enabled"),
272
+ hasSearch: this.session.settings.get("search.enabled"),
273
273
  hasFind: this.session.settings.get("find.enabled"),
274
274
  });
275
275
  }
@@ -1,4 +1,5 @@
1
1
  import * as path from "node:path";
2
+ import { formatPathRelativeToCwd } from "./path-utils";
2
3
 
3
4
  /**
4
5
  * Creates a deduplicating recorder for relative file paths.
@@ -22,14 +23,13 @@ export function createFileRecorder(): {
22
23
  }
23
24
 
24
25
  /**
25
- * Strip a leading slash and, when the search scope is a directory, normalize
26
- * Windows-style separators. For single-file scopes, fall back to the basename
27
- * so tool output does not leak absolute paths.
26
+ * Strip native virtual-root prefixes and format file paths relative to cwd when
27
+ * they are inside cwd. Paths outside cwd remain absolute.
28
28
  */
29
- export function formatResultPath(filePath: string, isDirectory: boolean): string {
29
+ export function formatResultPath(filePath: string, isDirectory: boolean, basePath: string, cwd: string): string {
30
30
  const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
31
31
  if (isDirectory) {
32
- return cleanPath.replace(/\\/g, "/");
32
+ return formatPathRelativeToCwd(path.resolve(basePath, cleanPath), cwd);
33
33
  }
34
- return path.basename(cleanPath);
34
+ return formatPathRelativeToCwd(basePath, cwd);
35
35
  }
package/src/tools/find.ts CHANGED
@@ -23,7 +23,13 @@ import {
23
23
  import type { ToolSession } from ".";
24
24
  import { applyListLimit } from "./list-limit";
25
25
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
26
- import { normalizePathLikeInput, parseFindPattern, resolveMultiFindPattern, resolveToCwd } from "./path-utils";
26
+ import {
27
+ formatPathRelativeToCwd,
28
+ normalizePathLikeInput,
29
+ parseFindPattern,
30
+ resolveMultiFindPattern,
31
+ resolveToCwd,
32
+ } from "./path-utils";
27
33
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
28
34
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
29
35
  import { toolResult } from "./tool-result";
@@ -101,10 +107,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
101
107
  const { pattern, limit, hidden } = params;
102
108
 
103
109
  return untilAborted(signal, async () => {
104
- const formatScopePath = (targetPath: string): string => {
105
- const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
106
- return relative.length === 0 ? "." : relative;
107
- };
110
+ const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
108
111
  const normalizedPattern = normalizePathLikeInput(pattern).replace(/\\/g, "/");
109
112
  if (!normalizedPattern) {
110
113
  throw new ToolError("Pattern must not be empty");
@@ -132,14 +135,9 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
132
135
  const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
133
136
  const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
134
137
  const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(searchPath, matchPath);
135
- let relativePath = path.relative(this.session.cwd, absolutePath).replace(/\\/g, "/");
136
- if (relativePath.length === 0) {
137
- relativePath = ".";
138
- }
139
- if ((fileType === natives.FileType.Dir || hadTrailingSlash) && !relativePath.endsWith("/")) {
140
- relativePath += "/";
141
- }
142
- return relativePath;
138
+ return formatPathRelativeToCwd(absolutePath, this.session.cwd, {
139
+ trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash,
140
+ });
143
141
  };
144
142
 
145
143
  const buildResult = (files: string[]): AgentToolResult<FindToolDetails> => {
@@ -0,0 +1,96 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * One file's contribution to a grouped file output. The header itself is generated
5
+ * by `formatGroupedFiles` (single `#` for root files, `##` for files inside a dir);
6
+ * use `headerSuffix` to tack on extras like ` (1 replacement)`.
7
+ */
8
+ export interface GroupedFileSection {
9
+ /** Optional suffix appended to the file header. */
10
+ headerSuffix?: string;
11
+ /** Body lines emitted into the textual model output. */
12
+ modelLines: string[];
13
+ /** Body lines emitted into the display output. Defaults to `modelLines`. */
14
+ displayLines?: string[];
15
+ /** When true, the file (and its header) is omitted entirely. */
16
+ skip?: boolean;
17
+ }
18
+
19
+ export interface GroupedFilesOutput {
20
+ model: string[];
21
+ display: string[];
22
+ }
23
+
24
+ /**
25
+ * Render a list of files as directory-grouped sections shared by grep, ast-grep,
26
+ * ast-edit, and the LSP diagnostic formatter.
27
+ *
28
+ * Layout:
29
+ * # dir/
30
+ * ## file.ts
31
+ * …body…
32
+ *
33
+ * # otherdir/
34
+ * ## other.ts
35
+ * …body…
36
+ *
37
+ * Files in the project root (directory `.`) become single-`#` headers without a
38
+ * `## file` line, matching the existing convention.
39
+ */
40
+ export function formatGroupedFiles(
41
+ files: string[],
42
+ renderFile: (filePath: string) => GroupedFileSection,
43
+ ): GroupedFilesOutput {
44
+ const filesByDirectory = new Map<string, string[]>();
45
+ for (const filePath of files) {
46
+ const directory = path.dirname(filePath).replace(/\\/g, "/");
47
+ if (!filesByDirectory.has(directory)) {
48
+ filesByDirectory.set(directory, []);
49
+ }
50
+ filesByDirectory.get(directory)!.push(filePath);
51
+ }
52
+
53
+ const model: string[] = [];
54
+ const display: string[] = [];
55
+
56
+ const pushSeparatorIfNeeded = () => {
57
+ if (model.length > 0) {
58
+ model.push("");
59
+ display.push("");
60
+ }
61
+ };
62
+
63
+ for (const [directory, dirFiles] of filesByDirectory) {
64
+ if (directory === ".") {
65
+ for (const filePath of dirFiles) {
66
+ const section = renderFile(filePath);
67
+ if (section.skip) continue;
68
+ pushSeparatorIfNeeded();
69
+ const header = `# ${path.basename(filePath)}${section.headerSuffix ?? ""}`;
70
+ model.push(header, ...section.modelLines);
71
+ display.push(header, ...(section.displayLines ?? section.modelLines));
72
+ }
73
+ continue;
74
+ }
75
+
76
+ const sections: Array<{ filePath: string; section: GroupedFileSection }> = [];
77
+ for (const filePath of dirFiles) {
78
+ const section = renderFile(filePath);
79
+ if (section.skip) continue;
80
+ sections.push({ filePath, section });
81
+ }
82
+ if (sections.length === 0) continue;
83
+
84
+ pushSeparatorIfNeeded();
85
+ const dirHeader = `# ${directory}/`;
86
+ model.push(dirHeader);
87
+ display.push(dirHeader);
88
+ for (const { filePath, section } of sections) {
89
+ const fileHeader = `## ${path.basename(filePath)}${section.headerSuffix ?? ""}`;
90
+ model.push(fileHeader, ...section.modelLines);
91
+ display.push(fileHeader, ...(section.displayLines ?? section.modelLines));
92
+ }
93
+ }
94
+
95
+ return { model, display };
96
+ }
@@ -18,7 +18,7 @@ import type { ToolChoiceQueue } from "../session/tool-choice-queue";
18
18
  import { TaskTool } from "../task";
19
19
  import type { AgentOutputManager } from "../task/output-manager";
20
20
  import type { EventBus } from "../utils/event-bus";
21
- import { SearchTool } from "../web/search";
21
+ import { WebSearchTool } from "../web/search";
22
22
  import { AskTool } from "./ask";
23
23
  import { AstEditTool } from "./ast-edit";
24
24
  import { AstGrepTool } from "./ast-grep";
@@ -30,7 +30,6 @@ import { DebugTool } from "./debug";
30
30
  import { ExitPlanModeTool } from "./exit-plan-mode";
31
31
  import { FindTool } from "./find";
32
32
  import { GithubTool } from "./gh";
33
- import { GrepTool } from "./grep";
34
33
  import { InspectImageTool } from "./inspect-image";
35
34
  import { IrcTool } from "./irc";
36
35
  import { JobTool } from "./job";
@@ -42,6 +41,7 @@ import { RenderMermaidTool } from "./render-mermaid";
42
41
  import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
43
42
  import { ResolveTool } from "./resolve";
44
43
  import { reportFindingTool } from "./review";
44
+ import { SearchTool } from "./search";
45
45
  import { SearchToolBm25Tool } from "./search-tool-bm25";
46
46
  import { loadSshTool } from "./ssh";
47
47
  import { type TodoPhase, TodoWriteTool } from "./todo-write";
@@ -68,7 +68,6 @@ export * from "./debug";
68
68
  export * from "./exit-plan-mode";
69
69
  export * from "./find";
70
70
  export * from "./gh";
71
- export * from "./grep";
72
71
  export * from "./image-gen";
73
72
  export * from "./inspect-image";
74
73
  export * from "./irc";
@@ -80,6 +79,7 @@ export * from "./render-mermaid";
80
79
  export * from "./report-tool-issue";
81
80
  export * from "./resolve";
82
81
  export * from "./review";
82
+ export * from "./search";
83
83
  export * from "./search-tool-bm25";
84
84
  export * from "./ssh";
85
85
  export * from "./todo-write";
@@ -214,7 +214,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
214
214
  edit: s => new EditTool(s),
215
215
  github: GithubTool.createIf,
216
216
  find: s => new FindTool(s),
217
- grep: s => new GrepTool(s),
217
+ search: s => new SearchTool(s),
218
218
  lsp: LspTool.createIf,
219
219
  notebook: s => new NotebookTool(s),
220
220
  read: s => new ReadTool(s),
@@ -226,7 +226,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
226
226
  job: JobTool.createIf,
227
227
  irc: IrcTool.createIf,
228
228
  todo_write: s => new TodoWriteTool(s),
229
- web_search: s => new SearchTool(s),
229
+ web_search: s => new WebSearchTool(s),
230
230
  search_tool_bm25: SearchToolBm25Tool.createIf,
231
231
  write: s => new WriteTool(s),
232
232
  };
@@ -357,7 +357,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
357
357
  // Auto-include AST counterparts when their text-based sibling is present
358
358
  if (requestedTools) {
359
359
  if (
360
- requestedTools.includes("grep") &&
360
+ requestedTools.includes("search") &&
361
361
  !requestedTools.includes("ast_grep") &&
362
362
  session.settings.get("astGrep.enabled")
363
363
  ) {
@@ -379,7 +379,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
379
379
  if (name === "debug") return session.settings.get("debug.enabled");
380
380
  if (name === "todo_write") return !includeYield && session.settings.get("todo.enabled");
381
381
  if (name === "find") return session.settings.get("find.enabled");
382
- if (name === "grep") return session.settings.get("grep.enabled");
382
+ if (name === "search") return session.settings.get("search.enabled");
383
383
  if (name === "github") return session.settings.get("github.enabled");
384
384
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
385
385
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");