@oh-my-pi/pi-coding-agent 14.2.0 → 14.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/package.json +19 -19
  3. package/src/cli/args.ts +10 -1
  4. package/src/cli/shell-cli.ts +15 -3
  5. package/src/config/settings-schema.ts +60 -1
  6. package/src/dap/session.ts +8 -2
  7. package/src/debug/system-info.ts +6 -2
  8. package/src/discovery/claude.ts +58 -36
  9. package/src/discovery/opencode.ts +20 -2
  10. package/src/edit/index.ts +3 -1
  11. package/src/edit/modes/chunk.ts +133 -53
  12. package/src/edit/modes/hashline.ts +36 -11
  13. package/src/edit/renderer.ts +98 -133
  14. package/src/edit/streaming.ts +351 -0
  15. package/src/exec/bash-executor.ts +60 -5
  16. package/src/internal-urls/docs-index.generated.ts +5 -5
  17. package/src/internal-urls/pi-protocol.ts +0 -2
  18. package/src/lsp/client.ts +22 -6
  19. package/src/lsp/defaults.json +2 -1
  20. package/src/lsp/index.ts +53 -10
  21. package/src/lsp/types.ts +2 -0
  22. package/src/modes/acp/acp-agent.ts +76 -2
  23. package/src/modes/components/assistant-message.ts +1 -34
  24. package/src/modes/components/hook-editor.ts +1 -1
  25. package/src/modes/components/tool-execution.ts +111 -101
  26. package/src/modes/controllers/input-controller.ts +1 -1
  27. package/src/modes/interactive-mode.ts +0 -2
  28. package/src/modes/theme/mermaid-cache.ts +13 -52
  29. package/src/modes/theme/theme.ts +2 -2
  30. package/src/prompts/system/system-prompt.md +1 -1
  31. package/src/prompts/tools/ast-grep.md +1 -0
  32. package/src/prompts/tools/browser.md +1 -0
  33. package/src/prompts/tools/chunk-edit.md +25 -22
  34. package/src/prompts/tools/gh-pr-push.md +2 -1
  35. package/src/prompts/tools/grep.md +4 -3
  36. package/src/prompts/tools/lsp.md +6 -0
  37. package/src/prompts/tools/read-chunk.md +46 -7
  38. package/src/prompts/tools/read.md +7 -4
  39. package/src/sdk.ts +8 -5
  40. package/src/session/agent-session.ts +36 -20
  41. package/src/session/session-manager.ts +228 -57
  42. package/src/session/streaming-output.ts +11 -0
  43. package/src/system-prompt.ts +7 -2
  44. package/src/task/executor.ts +1 -0
  45. package/src/tools/ast-edit.ts +37 -2
  46. package/src/tools/bash.ts +75 -12
  47. package/src/tools/find.ts +19 -26
  48. package/src/tools/gh.ts +6 -16
  49. package/src/tools/grep.ts +94 -37
  50. package/src/tools/path-utils.ts +31 -3
  51. package/src/tools/resolve.ts +12 -3
  52. package/src/tools/sqlite-reader.ts +116 -3
  53. package/src/tools/vim.ts +1 -1
  54. package/src/web/search/providers/codex.ts +129 -6
@@ -294,6 +294,23 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
294
294
  failOnParseError: false,
295
295
  });
296
296
  const dedupedApplyParseErrors = dedupeParseErrors(applyResult.parseErrors);
297
+ const { record: recordAppliedFile, list: appliedFileList } = createFileRecorder();
298
+ const appliedFileReplacementCounts = new Map<string, number>();
299
+ for (const fileChange of applyResult.fileChanges) {
300
+ const relativePath = formatPath(fileChange.path);
301
+ recordAppliedFile(relativePath);
302
+ appliedFileReplacementCounts.set(
303
+ relativePath,
304
+ (appliedFileReplacementCounts.get(relativePath) ?? 0) + fileChange.count,
305
+ );
306
+ }
307
+ for (const change of applyResult.changes) {
308
+ recordAppliedFile(formatPath(change.path));
309
+ }
310
+ const appliedFileReplacements = appliedFileList.map(filePath => ({
311
+ path: filePath,
312
+ count: appliedFileReplacementCounts.get(filePath) ?? 0,
313
+ }));
297
314
  const appliedDetails: AstEditToolDetails = {
298
315
  totalReplacements: applyResult.totalReplacements,
299
316
  filesTouched: applyResult.filesTouched,
@@ -302,9 +319,27 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
302
319
  limitReached: applyResult.limitReached,
303
320
  ...(dedupedApplyParseErrors.length > 0 ? { parseErrors: dedupedApplyParseErrors } : {}),
304
321
  scopePath,
305
- files: fileList,
306
- fileReplacements,
322
+ files: appliedFileList,
323
+ fileReplacements: appliedFileReplacements,
307
324
  };
325
+ const stalePreview =
326
+ applyResult.totalReplacements !== result.totalReplacements ||
327
+ applyResult.filesTouched !== result.filesTouched ||
328
+ fileList.some(
329
+ filePath => appliedFileReplacementCounts.get(filePath) !== fileReplacementCounts.get(filePath),
330
+ ) ||
331
+ appliedFileList.some(
332
+ filePath => fileReplacementCounts.get(filePath) !== appliedFileReplacementCounts.get(filePath),
333
+ );
334
+ if (stalePreview) {
335
+ const text =
336
+ applyResult.totalReplacements === 0
337
+ ? `Preview is stale / no longer matches; no replacements were applied. Preview expected ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}.`
338
+ : applyResult.totalReplacements < result.totalReplacements
339
+ ? `Preview is stale / no longer matches; only ${applyResult.totalReplacements} of ${result.totalReplacements} replacements were applied in ${applyResult.filesTouched} of ${result.filesTouched} files.`
340
+ : `Preview is stale / no longer matches; applied ${applyResult.totalReplacements} replacements but preview expected ${result.totalReplacements}.`;
341
+ return { ...toolResult(appliedDetails).text(text).done(), isError: true };
342
+ }
308
343
  const appliedReplacementPlural = applyResult.totalReplacements !== 1 ? "s" : "";
309
344
  const appliedFilePlural = applyResult.filesTouched !== 1 ? "s" : "";
310
345
  const text = `Applied ${applyResult.totalReplacements} replacement${appliedReplacementPlural} in ${applyResult.filesTouched} file${appliedFilePlural}.`;
package/src/tools/bash.ts CHANGED
@@ -23,13 +23,24 @@ import { resolveToCwd } from "./path-utils";
23
23
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
24
24
  import { ToolAbortError, ToolError } from "./tool-errors";
25
25
  import { toolResult } from "./tool-result";
26
- import { clampTimeout } from "./tool-timeouts";
26
+ import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
27
27
 
28
28
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
29
29
 
30
30
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
31
31
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
32
32
 
33
+ async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
34
+ try {
35
+ const alloc = await session.allocateOutputArtifact?.("bash-original");
36
+ if (!alloc?.path || !alloc.id) return undefined;
37
+ await Bun.write(alloc.path, originalText);
38
+ return alloc.id;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
33
44
  const bashSchemaBase = Type.Object({
34
45
  command: Type.String({ description: "Command to execute" }),
35
46
  env: Type.Optional(
@@ -74,6 +85,7 @@ export interface BashToolInput {
74
85
  export interface BashToolDetails {
75
86
  meta?: OutputMeta;
76
87
  timeoutSeconds?: number;
88
+ requestedTimeoutSeconds?: number;
77
89
  async?: {
78
90
  state: "running" | "completed" | "failed";
79
91
  jobId: string;
@@ -219,6 +231,13 @@ function getBashEnvForDisplay(args: BashRenderArgs): Record<string, string> | un
219
231
  if (partialEnv && args.env) return { ...partialEnv, ...args.env };
220
232
  return args.env ?? partialEnv;
221
233
  }
234
+
235
+ function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutSec: number): string | undefined {
236
+ return requestedTimeoutSec !== effectiveTimeoutSec
237
+ ? `Timeout clamped to ${effectiveTimeoutSec}s (requested ${requestedTimeoutSec}s; allowed range ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}s).`
238
+ : undefined;
239
+ }
240
+
222
241
  /**
223
242
  * Bash tool implementation.
224
243
  *
@@ -289,9 +308,16 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
289
308
  timeoutSec: number,
290
309
  headLines?: number,
291
310
  tailLines?: number,
311
+ options: { requestedTimeoutSec?: number; notices?: string[] } = {},
292
312
  ): AgentToolResult<BashToolDetails> {
293
- const outputText = this.#formatResultOutput(result, headLines, tailLines);
313
+ const outputLines = [this.#formatResultOutput(result, headLines, tailLines)];
314
+ const notices = options.notices?.filter(Boolean) ?? [];
315
+ if (notices.length > 0) outputLines.push("", ...notices);
316
+ const outputText = outputLines.join("\n");
294
317
  const details: BashToolDetails = { timeoutSeconds: timeoutSec };
318
+ if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
319
+ details.requestedTimeoutSeconds = options.requestedTimeoutSec;
320
+ }
295
321
  const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
296
322
  this.#buildResultText(result, timeoutSec, outputText);
297
323
  return resultBuilder.done();
@@ -302,16 +328,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
302
328
  label: string,
303
329
  previewText: string,
304
330
  timeoutSec: number,
331
+ options: { requestedTimeoutSec?: number; notices?: string[] } = {},
305
332
  ): AgentToolResult<BashToolDetails> {
306
333
  const details: BashToolDetails = {
307
334
  timeoutSeconds: timeoutSec,
308
335
  async: { state: "running", jobId, type: "bash" },
309
336
  };
337
+ if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
338
+ details.requestedTimeoutSeconds = options.requestedTimeoutSec;
339
+ }
310
340
  const lines: string[] = [];
311
341
  const trimmedPreview = previewText.trimEnd();
312
342
  if (trimmedPreview.length > 0) {
313
343
  lines.push(trimmedPreview, "");
314
344
  }
345
+ if (options.notices?.length) {
346
+ lines.push(...options.notices, "");
347
+ }
315
348
  lines.push(`Background job ${jobId} started: ${label}`);
316
349
  lines.push("Result will be delivered automatically when complete.");
317
350
  lines.push(`Use \`poll\`, \`read jobs://${jobId}\`, or \`cancel_job\` if needed.`);
@@ -330,6 +363,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
330
363
  commandCwd: string;
331
364
  timeoutMs: number;
332
365
  timeoutSec: number;
366
+ requestedTimeoutSec?: number;
367
+ timeoutClampNotice?: string;
333
368
  headLines?: number;
334
369
  tailLines?: number;
335
370
  resolvedEnv?: Record<string, string>;
@@ -366,12 +401,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
366
401
  latestText = tailBuffer.text();
367
402
  void reportProgress(latestText, { async: { state: "running", jobId, type: "bash" } });
368
403
  },
404
+ onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
369
405
  });
370
406
  const finalResult = this.#buildCompletedResult(
371
407
  result,
372
408
  options.timeoutSec,
373
409
  options.headLines,
374
410
  options.tailLines,
411
+ {
412
+ requestedTimeoutSec: options.requestedTimeoutSec,
413
+ notices: [options.timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
414
+ },
375
415
  );
376
416
  const finalText = this.#extractTextResult(finalResult);
377
417
  latestText = finalText;
@@ -531,8 +571,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
531
571
  }
532
572
 
533
573
  // Clamp to reasonable range: 1s - 3600s (1 hour)
534
- const timeoutSec = clampTimeout("bash", rawTimeout);
574
+ const requestedTimeoutSec = rawTimeout;
575
+ const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
535
576
  const timeoutMs = timeoutSec * 1000;
577
+ const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
536
578
 
537
579
  if (asyncRequested) {
538
580
  if (!this.session.asyncJobManager) {
@@ -543,13 +585,18 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
543
585
  commandCwd,
544
586
  timeoutMs,
545
587
  timeoutSec,
588
+ requestedTimeoutSec,
589
+ timeoutClampNotice,
546
590
  headLines,
547
591
  tailLines,
548
592
  resolvedEnv,
549
593
  onUpdate,
550
594
  startBackgrounded: true,
551
595
  });
552
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
596
+ return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
597
+ requestedTimeoutSec,
598
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
599
+ });
553
600
  }
554
601
 
555
602
  if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
@@ -560,6 +607,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
560
607
  commandCwd,
561
608
  timeoutMs,
562
609
  timeoutSec,
610
+ requestedTimeoutSec,
611
+ timeoutClampNotice,
563
612
  headLines,
564
613
  tailLines,
565
614
  resolvedEnv,
@@ -567,7 +616,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
567
616
  startBackgrounded,
568
617
  });
569
618
  if (startBackgrounded) {
570
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
619
+ return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
620
+ requestedTimeoutSec,
621
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
622
+ });
571
623
  }
572
624
  const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
573
625
  if (waitResult.kind === "completed") {
@@ -584,7 +636,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
584
636
  throw new ToolAbortError(job.getLatestText() || "Command aborted");
585
637
  }
586
638
  job.setBackgrounded(true);
587
- return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec);
639
+ return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
640
+ requestedTimeoutSec,
641
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
642
+ });
588
643
  }
589
644
 
590
645
  // Track output for streaming updates (tail only)
@@ -613,6 +668,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
613
668
  artifactPath,
614
669
  artifactId,
615
670
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
671
+ onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
616
672
  });
617
673
  if (result.cancelled) {
618
674
  if (signal?.aborted) {
@@ -623,7 +679,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
623
679
  if (isInteractiveResult(result) && result.timedOut) {
624
680
  throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
625
681
  }
626
- return this.#buildCompletedResult(result, timeoutSec, headLines, tailLines);
682
+ return this.#buildCompletedResult(result, timeoutSec, headLines, tailLines, {
683
+ requestedTimeoutSec,
684
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
685
+ });
627
686
  }
628
687
  }
629
688
 
@@ -700,12 +759,16 @@ export const bashToolRenderer = {
700
759
 
701
760
  // Build truncation warning
702
761
  const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
703
- const timeoutLine =
762
+ const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
763
+ const timeoutLabel =
704
764
  typeof timeoutSeconds === "number"
705
- ? uiTheme.fg(
706
- "dim",
707
- `${uiTheme.format.bracketLeft}Timeout: ${timeoutSeconds}s${uiTheme.format.bracketRight}`,
708
- )
765
+ ? requestedTimeoutSeconds !== undefined && requestedTimeoutSeconds !== timeoutSeconds
766
+ ? `Timeout: ${timeoutSeconds}s (requested ${requestedTimeoutSeconds}s clamped)`
767
+ : `Timeout: ${timeoutSeconds}s`
768
+ : undefined;
769
+ const timeoutLine =
770
+ timeoutLabel !== undefined
771
+ ? uiTheme.fg("dim", `${uiTheme.format.bracketLeft}${timeoutLabel}${uiTheme.format.bracketRight}`)
709
772
  : undefined;
710
773
  let warningLine: string | undefined;
711
774
  if (details?.meta?.truncation && !showingFullOutput) {
package/src/tools/find.ts CHANGED
@@ -129,6 +129,19 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
129
129
  const includeHidden = hidden ?? true;
130
130
  const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
131
131
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
132
+ const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
133
+ const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
134
+ 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;
143
+ };
144
+
132
145
  const buildResult = (files: string[]): AgentToolResult<FindToolDetails> => {
133
146
  if (files.length === 0) {
134
147
  const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
@@ -176,12 +189,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
176
189
  ignore: ["**/node_modules/**", "**/.git/**"],
177
190
  limit: effectiveLimit,
178
191
  });
179
- const relativized = results.map(p => {
180
- if (p.startsWith(searchPath)) {
181
- return p.slice(searchPath.length + 1);
182
- }
183
- return path.relative(searchPath, p);
184
- });
192
+ const relativized = results.map(p => formatMatchPath(p));
185
193
 
186
194
  return buildResult(relativized);
187
195
  }
@@ -225,12 +233,8 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
225
233
  };
226
234
  const onMatch = onUpdate
227
235
  ? (err: Error | null, match: natives.GlobMatch | null) => {
228
- if (err || signal?.aborted || !match) return;
229
- let relativePath = match.path;
230
- if (!relativePath) return;
231
- if (match.fileType === natives.FileType.Dir && !relativePath.endsWith("/")) {
232
- relativePath += "/";
233
- }
236
+ if (err || signal?.aborted || !match?.path) return;
237
+ const relativePath = formatMatchPath(match.path, match.fileType);
234
238
  onUpdateMatches.push(relativePath);
235
239
  emitUpdate();
236
240
  }
@@ -254,10 +258,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
254
258
  );
255
259
 
256
260
  try {
257
- let result = await doGlob(true);
258
- if (result.matches.length === 0 && !timeoutSignal.aborted) {
259
- result = await doGlob(false);
260
- }
261
+ const result = await doGlob(true);
261
262
  // Sort by mtime descending (most recent first) in JS instead of native.
262
263
  // This allows native glob to early-terminate at maxResults.
263
264
  result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
@@ -276,19 +277,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
276
277
  const relativized: string[] = [];
277
278
  for (const match of matches) {
278
279
  throwIfAborted(signal);
279
- const line = match.path;
280
- if (!line) {
280
+ if (!match.path) {
281
281
  continue;
282
282
  }
283
283
 
284
- const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
285
- let relativePath = line;
286
- const isDirectory = match.fileType === natives.FileType.Dir;
287
- if ((isDirectory || hadTrailingSlash) && !relativePath.endsWith("/")) {
288
- relativePath += "/";
289
- }
290
-
291
- relativized.push(relativePath);
284
+ relativized.push(formatMatchPath(match.path, match.fileType));
292
285
  }
293
286
 
294
287
  return buildResult(relativized);
package/src/tools/gh.ts CHANGED
@@ -611,14 +611,6 @@ function toLocalBranchRef(value: string): string {
611
611
  return `refs/heads/${value}`;
612
612
  }
613
613
 
614
- function stripHeadsRef(value: string | undefined): string | undefined {
615
- if (!value) {
616
- return undefined;
617
- }
618
-
619
- return value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value;
620
- }
621
-
622
614
  async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
623
615
  const repoRoot = await git.repo.root(cwd, signal);
624
616
  if (!repoRoot) {
@@ -763,10 +755,13 @@ async function resolvePrBranchPushTarget(
763
755
  maintainerCanModify?: boolean;
764
756
  isCrossRepository: boolean;
765
757
  }> {
758
+ const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
759
+ if (!headRef) {
760
+ throw new ToolError(`branch ${localBranch} has no PR push metadata; check it out via gh_pr_checkout first`);
761
+ }
762
+
766
763
  const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
767
764
  const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
768
- const mergeRef = await git.config.getBranch(repoRoot, localBranch, "merge", signal);
769
- const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
770
765
  const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal);
771
766
  const maintainerCanModifyValue = await git.config.getBranch(
772
767
  repoRoot,
@@ -781,14 +776,9 @@ async function resolvePrBranchPushTarget(
781
776
  throw new ToolError(`branch ${localBranch} has no configured push remote`);
782
777
  }
783
778
 
784
- const remoteBranch = headRef ?? stripHeadsRef(mergeRef);
785
- if (!remoteBranch) {
786
- throw new ToolError(`branch ${localBranch} has no tracked PR head ref`);
787
- }
788
-
789
779
  return {
790
780
  remoteName,
791
- remoteBranch,
781
+ remoteBranch: headRef,
792
782
  remoteUrl: await git.remote.url(repoRoot, remoteName, signal),
793
783
  prUrl,
794
784
  maintainerCanModify:
package/src/tools/grep.ts CHANGED
@@ -124,6 +124,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
124
124
  };
125
125
  let searchPath: string;
126
126
  let scopePath: string;
127
+ let exactFilePaths: string[] | undefined;
127
128
  let globFilter = glob ? normalizePathLikeInput(glob) || undefined : undefined;
128
129
  const internalRouter = this.session.internalRouter;
129
130
  if (searchDir?.trim()) {
@@ -142,7 +143,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
142
143
  const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
143
144
  if (multiSearchPath) {
144
145
  searchPath = multiSearchPath.basePath;
145
- globFilter = multiSearchPath.glob;
146
+ globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
147
+ exactFilePaths = multiSearchPath.exactFilePaths;
146
148
  scopePath = multiSearchPath.scopePath;
147
149
  } else {
148
150
  const parsedPath = parseSearchPath(rawPath);
@@ -174,26 +176,61 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
174
176
  // Run grep
175
177
  let result: GrepResult;
176
178
  try {
177
- result = await grep(
178
- {
179
- pattern: normalizedPattern,
180
- path: searchPath,
181
- glob: globFilter,
182
- type: type?.trim() || undefined,
183
- ignoreCase,
184
- multiline: effectiveMultiline,
185
- hidden: true,
186
- gitignore: useGitignore,
187
- cache: false,
188
- maxCount: internalLimit,
189
- offset: normalizedOffset > 0 ? normalizedOffset : undefined,
190
- contextBefore: normalizedContextBefore,
191
- contextAfter: normalizedContextAfter,
192
- maxColumns: DEFAULT_MAX_COLUMN,
193
- mode: effectiveOutputMode,
194
- },
195
- undefined,
196
- );
179
+ if (exactFilePaths) {
180
+ const matches: GrepMatch[] = [];
181
+ let limitReached = false;
182
+ for (const exactFilePath of exactFilePaths) {
183
+ const fileResult = await grep(
184
+ {
185
+ pattern: normalizedPattern,
186
+ path: exactFilePath,
187
+ type: type?.trim() || undefined,
188
+ ignoreCase,
189
+ multiline: effectiveMultiline,
190
+ hidden: true,
191
+ gitignore: useGitignore,
192
+ cache: false,
193
+ contextBefore: normalizedContextBefore,
194
+ contextAfter: normalizedContextAfter,
195
+ maxColumns: DEFAULT_MAX_COLUMN,
196
+ mode: effectiveOutputMode,
197
+ },
198
+ undefined,
199
+ );
200
+ limitReached = limitReached || Boolean(fileResult.limitReached);
201
+ const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/");
202
+ matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath })));
203
+ }
204
+ const offsetMatches = matches.slice(normalizedOffset);
205
+ result = {
206
+ matches: offsetMatches,
207
+ totalMatches: offsetMatches.length,
208
+ filesWithMatches: new Set(offsetMatches.map(match => match.path)).size,
209
+ filesSearched: exactFilePaths.length,
210
+ limitReached,
211
+ };
212
+ } else {
213
+ result = await grep(
214
+ {
215
+ pattern: normalizedPattern,
216
+ path: searchPath,
217
+ glob: globFilter,
218
+ type: type?.trim() || undefined,
219
+ ignoreCase,
220
+ multiline: effectiveMultiline,
221
+ hidden: true,
222
+ gitignore: useGitignore,
223
+ cache: false,
224
+ maxCount: internalLimit,
225
+ offset: normalizedOffset > 0 ? normalizedOffset : undefined,
226
+ contextBefore: normalizedContextBefore,
227
+ contextAfter: normalizedContextAfter,
228
+ maxColumns: DEFAULT_MAX_COLUMN,
229
+ mode: effectiveOutputMode,
230
+ },
231
+ undefined,
232
+ );
233
+ }
197
234
  } catch (err) {
198
235
  if (err instanceof Error && err.message.startsWith("regex parse error")) {
199
236
  throw new ToolError(err.message);
@@ -258,6 +295,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
258
295
  }
259
296
  const outputLines: string[] = [];
260
297
  let linesTruncated = false;
298
+ const hasContextLines = normalizedContextBefore > 0 || normalizedContextAfter > 0;
261
299
  const matchesByFile = new Map<string, GrepMatch[]>();
262
300
  for (const match of selectedMatches) {
263
301
  const relativePath = formatPath(match.path);
@@ -289,10 +327,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
289
327
  }
290
328
  chunkMatchesByFile.get(match.displayPath)!.push(match);
291
329
  }
292
- const renderChunkedMatchesForFile = (relativePath: string) => {
330
+ const renderChunkedMatchesForFile = (relativePath: string): string[] => {
331
+ const renderedLines: string[] = [];
293
332
  const fileMatches = chunkMatchesByFile.get(relativePath) ?? [];
294
333
  if (fileMatches.length === 0) {
295
- return;
334
+ return renderedLines;
296
335
  }
297
336
  const lineWidth = fileMatches[0]?.fileLineCount.toString().length ?? 1;
298
337
  const matchesByChunk = new Map<string, ChunkedGrepMatch[]>();
@@ -310,13 +349,14 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
310
349
  const anchor = chunkChecksum
311
350
  ? `${dashes}@${chunkPath}#${chunkChecksum}`
312
351
  : `${dashes}@${chunkPath}`;
313
- outputLines.push(anchor);
352
+ renderedLines.push(anchor);
314
353
  }
315
354
  for (const match of chunkMatches) {
316
- outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
355
+ renderedLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
317
356
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
318
357
  }
319
358
  }
359
+ return renderedLines;
320
360
  };
321
361
  if (isDirectory) {
322
362
  const filesByDirectory = new Map<string, string[]>();
@@ -330,26 +370,32 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
330
370
  for (const [directory, directoryFiles] of filesByDirectory) {
331
371
  if (directory === ".") {
332
372
  for (const relativePath of directoryFiles) {
373
+ const renderedLines = renderChunkedMatchesForFile(relativePath);
374
+ if (renderedLines.length === 0) continue;
333
375
  if (outputLines.length > 0) {
334
376
  outputLines.push("");
335
377
  }
336
378
  outputLines.push(`# ${path.basename(relativePath)}`);
337
- renderChunkedMatchesForFile(relativePath);
379
+ outputLines.push(...renderedLines);
338
380
  }
339
381
  continue;
340
382
  }
383
+ const renderedFiles = directoryFiles
384
+ .map(relativePath => ({ relativePath, lines: renderChunkedMatchesForFile(relativePath) }))
385
+ .filter(file => file.lines.length > 0);
386
+ if (renderedFiles.length === 0) continue;
341
387
  if (outputLines.length > 0) {
342
388
  outputLines.push("");
343
389
  }
344
390
  outputLines.push(`# ${directory}`);
345
- for (const relativePath of directoryFiles) {
391
+ for (const { relativePath, lines } of renderedFiles) {
346
392
  outputLines.push(`## └─ ${path.basename(relativePath)}`);
347
- renderChunkedMatchesForFile(relativePath);
393
+ outputLines.push(...lines);
348
394
  }
349
395
  }
350
396
  } else {
351
397
  for (const relativePath of fileList) {
352
- renderChunkedMatchesForFile(relativePath);
398
+ outputLines.push(...renderChunkedMatchesForFile(relativePath));
353
399
  }
354
400
  }
355
401
  const rawOutput = outputLines.join("\n");
@@ -380,7 +426,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
380
426
  }
381
427
  return resultBuilder.done();
382
428
  }
383
- const renderMatchesForFile = (relativePath: string) => {
429
+ const renderMatchesForFile = (relativePath: string): string[] => {
430
+ const renderedLines: string[] = [];
384
431
  const fileMatches = matchesByFile.get(relativePath) ?? [];
385
432
  for (const match of fileMatches) {
386
433
  const lineNumbers: number[] = [match.lineNumber];
@@ -399,20 +446,21 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
399
446
  formatMatchLine(lineNumber, line, isMatch, { useHashLines, lineWidth });
400
447
  if (match.contextBefore) {
401
448
  for (const ctx of match.contextBefore) {
402
- outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
449
+ renderedLines.push(formatLine(ctx.lineNumber, ctx.line, false));
403
450
  }
404
451
  }
405
- outputLines.push(formatLine(match.lineNumber, match.line, true));
452
+ renderedLines.push(formatLine(match.lineNumber, match.line, true));
406
453
  if (match.truncated) {
407
454
  linesTruncated = true;
408
455
  }
409
456
  if (match.contextAfter) {
410
457
  for (const ctx of match.contextAfter) {
411
- outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
458
+ renderedLines.push(formatLine(ctx.lineNumber, ctx.line, false));
412
459
  }
413
460
  }
414
461
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
415
462
  }
463
+ return renderedLines;
416
464
  };
417
465
  if (isDirectory) {
418
466
  const filesByDirectory = new Map<string, string[]>();
@@ -426,28 +474,37 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
426
474
  for (const [directory, directoryFiles] of filesByDirectory) {
427
475
  if (directory === ".") {
428
476
  for (const relativePath of directoryFiles) {
477
+ const renderedLines = renderMatchesForFile(relativePath);
478
+ if (renderedLines.length === 0) continue;
429
479
  if (outputLines.length > 0) {
430
480
  outputLines.push("");
431
481
  }
432
482
  outputLines.push(`# ${path.basename(relativePath)}`);
433
- renderMatchesForFile(relativePath);
483
+ outputLines.push(...renderedLines);
434
484
  }
435
485
  continue;
436
486
  }
487
+ const renderedFiles = directoryFiles
488
+ .map(relativePath => ({ relativePath, lines: renderMatchesForFile(relativePath) }))
489
+ .filter(file => file.lines.length > 0);
490
+ if (renderedFiles.length === 0) continue;
437
491
  if (outputLines.length > 0) {
438
492
  outputLines.push("");
439
493
  }
440
494
  outputLines.push(`# ${directory}`);
441
- for (const relativePath of directoryFiles) {
495
+ for (const { relativePath, lines } of renderedFiles) {
442
496
  outputLines.push(`## └─ ${path.basename(relativePath)}`);
443
- renderMatchesForFile(relativePath);
497
+ outputLines.push(...lines);
444
498
  }
445
499
  }
446
500
  } else {
447
501
  for (const relativePath of fileList) {
448
- renderMatchesForFile(relativePath);
502
+ outputLines.push(...renderMatchesForFile(relativePath));
449
503
  }
450
504
  }
505
+ if (hasContextLines && outputLines.length > 0) {
506
+ outputLines.unshift("[grep] match lines use ':'; context lines use '-'.");
507
+ }
451
508
  const rawOutput = outputLines.join("\n");
452
509
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
453
510
  const output = truncation.content;