@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -15,7 +15,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
15
15
  import { getDefault, type Settings } from "../config/settings";
16
16
  import { formatGroupedDiagnosticMessages } from "../lsp/utils";
17
17
  import type { Theme } from "../modes/theme/theme";
18
- import { type OutputSummary, type TruncationResult, truncateTail } from "../session/streaming-output";
18
+ import { type OutputSummary, type TruncationResult, truncateMiddle, truncateTail } from "../session/streaming-output";
19
19
  import { formatBytes, wrapBrackets } from "./render-utils";
20
20
  import { renderError } from "./tool-errors";
21
21
 
@@ -23,15 +23,22 @@ import { renderError } from "./tool-errors";
23
23
  * Truncation metadata for the output notice.
24
24
  */
25
25
  export interface TruncationMeta {
26
- direction: "head" | "tail";
27
- truncatedBy: "lines" | "bytes";
26
+ direction: "head" | "tail" | "middle";
27
+ truncatedBy: "lines" | "bytes" | "middle";
28
28
  totalLines: number;
29
29
  totalBytes: number;
30
30
  outputLines: number;
31
31
  outputBytes: number;
32
32
  maxBytes?: number;
33
- /** Line range shown (1-indexed, inclusive) */
33
+ /** Line range shown (1-indexed, inclusive). Omitted for middle elision. */
34
34
  shownRange?: { start: number; end: number };
35
+ /** Head/tail line ranges shown when direction === "middle". */
36
+ headRange?: { start: number; end: number };
37
+ tailRange?: { start: number; end: number };
38
+ /** Bytes elided from the middle. */
39
+ elidedBytes?: number;
40
+ /** Lines elided from the middle. */
41
+ elidedLines?: number;
35
42
  /** Artifact ID if full output was saved */
36
43
  artifactId?: string;
37
44
  /** Next offset for pagination (head truncation only) */
@@ -79,20 +86,20 @@ export interface OutputMeta {
79
86
  // =============================================================================
80
87
 
81
88
  export interface TruncationOptions {
82
- direction: "head" | "tail";
89
+ direction: "head" | "tail" | "middle";
83
90
  startLine?: number;
84
91
  totalFileLines?: number;
85
92
  artifactId?: string;
86
93
  }
87
94
 
88
95
  export interface TruncationSummaryOptions {
89
- direction: "head" | "tail";
96
+ direction: "head" | "tail" | "middle";
90
97
  startLine?: number;
91
98
  totalFileLines?: number;
92
99
  }
93
100
 
94
101
  export interface TruncationTextOptions {
95
- direction: "head" | "tail";
102
+ direction: "head" | "tail" | "middle";
96
103
  totalLines?: number;
97
104
  totalBytes?: number;
98
105
  maxBytes?: number;
@@ -120,7 +127,40 @@ export class OutputMetaBuilder {
120
127
  const { direction, startLine = 1, totalFileLines, artifactId } = options;
121
128
  const outputLines = result.outputLines ?? result.totalLines;
122
129
  const outputBytes = result.outputBytes ?? result.totalBytes;
123
- const truncatedBy: "lines" | "bytes" = result.truncatedBy === "lines" ? "lines" : "bytes";
130
+ const isMiddle = direction === "middle" || result.truncatedBy === "middle";
131
+ const truncatedBy: "lines" | "bytes" | "middle" = isMiddle
132
+ ? "middle"
133
+ : result.truncatedBy === "lines"
134
+ ? "lines"
135
+ : "bytes";
136
+
137
+ const effectiveTotalLines = totalFileLines ?? result.totalLines;
138
+
139
+ if (isMiddle) {
140
+ const elidedLines = result.elidedLines ?? Math.max(0, effectiveTotalLines - outputLines);
141
+ const elidedBytes = result.elidedBytes ?? Math.max(0, result.totalBytes - outputBytes);
142
+ // Reconstruct head/tail line ranges. The kept output spans the first
143
+ // `headLines` lines and the last `tailLines` lines of the source; lines
144
+ // in the middle (count == elidedLines) are dropped.
145
+ const keptLines = Math.max(0, outputLines - 1); // -1 for marker line
146
+ const headLines = Math.ceil(keptLines / 2);
147
+ const tailLines = keptLines - headLines;
148
+ this.#meta.truncation = {
149
+ direction: "middle",
150
+ truncatedBy: "middle",
151
+ totalLines: effectiveTotalLines,
152
+ totalBytes: result.totalBytes,
153
+ outputLines,
154
+ outputBytes,
155
+ headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
156
+ tailRange:
157
+ tailLines > 0 ? { start: effectiveTotalLines - tailLines + 1, end: effectiveTotalLines } : undefined,
158
+ elidedLines,
159
+ elidedBytes,
160
+ artifactId,
161
+ };
162
+ return this;
163
+ }
124
164
 
125
165
  let shownStart: number;
126
166
  let shownEnd: number;
@@ -136,7 +176,7 @@ export class OutputMetaBuilder {
136
176
  this.#meta.truncation = {
137
177
  direction,
138
178
  truncatedBy,
139
- totalLines: totalFileLines ?? result.totalLines,
179
+ totalLines: effectiveTotalLines,
140
180
  totalBytes: result.totalBytes,
141
181
  outputLines,
142
182
  outputBytes,
@@ -154,6 +194,29 @@ export class OutputMetaBuilder {
154
194
 
155
195
  const { direction, startLine = 1, totalFileLines } = options;
156
196
  const totalLines = totalFileLines ?? summary.totalLines;
197
+
198
+ // Middle elision: the sink retained head + tail with an elision marker.
199
+ if (summary.elidedBytes != null && summary.elidedBytes > 0) {
200
+ const elidedLines = summary.elidedLines ?? Math.max(0, totalLines - summary.outputLines);
201
+ const keptLines = Math.max(0, summary.outputLines - 1); // -1 for marker line
202
+ const headLines = Math.ceil(keptLines / 2);
203
+ const tailLines = keptLines - headLines;
204
+ this.#meta.truncation = {
205
+ direction: "middle",
206
+ truncatedBy: "middle",
207
+ totalLines,
208
+ totalBytes: summary.totalBytes,
209
+ outputLines: summary.outputLines,
210
+ outputBytes: summary.outputBytes,
211
+ headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
212
+ tailRange: tailLines > 0 ? { start: totalLines - tailLines + 1, end: totalLines } : undefined,
213
+ elidedBytes: summary.elidedBytes,
214
+ elidedLines,
215
+ artifactId: summary.artifactId,
216
+ };
217
+ return this;
218
+ }
219
+
157
220
  const truncatedBy: "lines" | "bytes" =
158
221
  summary.outputBytes < summary.totalBytes
159
222
  ? "bytes"
@@ -322,9 +385,28 @@ export function formatFullOutputReference(artifactId: string): string {
322
385
  }
323
386
 
324
387
  export function formatTruncationMetaNotice(truncation: TruncationMeta): string {
325
- const range = truncation.shownRange;
326
388
  let notice: string;
327
389
 
390
+ if (truncation.direction === "middle") {
391
+ const head = truncation.headRange;
392
+ const tail = truncation.tailRange;
393
+ const totalLines = truncation.totalLines;
394
+ const elidedBytes = truncation.elidedBytes ?? Math.max(0, truncation.totalBytes - truncation.outputBytes);
395
+ const elidedLines = truncation.elidedLines ?? Math.max(0, totalLines - truncation.outputLines);
396
+ const headPart = head ? `lines ${head.start}-${head.end}` : "";
397
+ const tailPart = tail ? `${tail.start}-${tail.end}` : "";
398
+ if (headPart && tailPart) {
399
+ notice = `Showing ${headPart} and ${tailPart} of ${totalLines}; ${elidedLines.toLocaleString()} middle line${elidedLines === 1 ? "" : "s"} (${formatBytes(elidedBytes)}) elided`;
400
+ } else {
401
+ notice = `Showing ${truncation.outputLines} of ${totalLines} lines; middle elided`;
402
+ }
403
+ if (truncation.artifactId != null) {
404
+ notice += `. ${formatFullOutputReference(truncation.artifactId)}`;
405
+ }
406
+ return notice;
407
+ }
408
+
409
+ const range = truncation.shownRange;
328
410
  if (range && range.end >= range.start) {
329
411
  notice = `Showing lines ${range.start}-${range.end} of ${truncation.totalLines}`;
330
412
  } else {
@@ -442,21 +524,44 @@ const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute");
442
524
 
443
525
  /** Resolved artifact spill config sourced from the session settings (or schema defaults). */
444
526
  function getSpillConfig(s: Settings | undefined) {
445
- const get = <P extends "tools.artifactSpillThreshold" | "tools.artifactTailBytes" | "tools.artifactTailLines">(
446
- path: P,
447
- ) => s?.get(path) ?? getDefault(path);
527
+ type Path =
528
+ | "tools.artifactSpillThreshold"
529
+ | "tools.artifactTailBytes"
530
+ | "tools.artifactTailLines"
531
+ | "tools.artifactHeadBytes";
532
+ const get = <P extends Path>(path: P) => s?.get(path) ?? getDefault(path);
448
533
  return {
449
534
  threshold: get("tools.artifactSpillThreshold") * 1024,
450
535
  tailBytes: get("tools.artifactTailBytes") * 1024,
451
536
  tailLines: get("tools.artifactTailLines"),
537
+ headBytes: get("tools.artifactHeadBytes") * 1024,
452
538
  };
453
539
  }
454
540
 
455
541
  /**
456
- * If the tool result text exceeds RESULT_ARTIFACT_THRESHOLD, save the full
457
- * output as a session artifact and replace the content with a tail-truncated
458
- * version plus an artifact reference. Skips when the tool already saved its
459
- * own artifact (e.g. bash/python via OutputSink).
542
+ * Resolve the OutputSink `headBytes` budget from session settings.
543
+ * Exposed so streaming executors (bash/python/ssh/eval) can opt into
544
+ * middle elision with the same per-user configuration.
545
+ */
546
+ export function resolveOutputSinkHeadBytes(s: Settings | undefined): number {
547
+ return getSpillConfig(s).headBytes;
548
+ }
549
+
550
+ /**
551
+ * Resolve the per-line column cap from session settings. Shared by streaming
552
+ * executors (bash/python/ssh/eval via OutputSink) and the `read` tool's
553
+ * line-buffer post-processing, so one setting controls both surfaces.
554
+ */
555
+ export function resolveOutputMaxColumns(s: Settings | undefined): number {
556
+ return s?.get("tools.outputMaxColumns") ?? getDefault("tools.outputMaxColumns");
557
+ }
558
+
559
+ /**
560
+ * If the tool result text exceeds the spill threshold, save the full output
561
+ * as a session artifact and replace the content with a head+tail (middle
562
+ * elision) view plus an artifact reference. When `tools.artifactHeadBytes`
563
+ * is 0, falls back to tail-only truncation. Skips when the tool already
564
+ * saved its own artifact (e.g. bash/python via OutputSink).
460
565
  */
461
566
  async function spillLargeResultToArtifact(
462
567
  result: AgentToolResult,
@@ -466,7 +571,7 @@ async function spillLargeResultToArtifact(
466
571
  const sessionManager = context?.sessionManager;
467
572
  if (!sessionManager) return result;
468
573
  if (toolName === "read") return result;
469
- const { threshold, tailBytes, tailLines } = getSpillConfig(context?.settings);
574
+ const { threshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
470
575
 
471
576
  // Skip if tool already saved an artifact
472
577
  const existingMeta: OutputMeta | undefined = result.details?.meta;
@@ -489,13 +594,21 @@ async function spillLargeResultToArtifact(
489
594
  const artifactId = await sessionManager.saveArtifact(fullText, toolName);
490
595
  if (!artifactId) return result;
491
596
 
492
- // Truncate to tail
493
- const truncated = truncateTail(fullText, {
494
- maxBytes: tailBytes,
495
- maxLines: tailLines,
496
- });
497
-
498
- // Replace text blocks with single tail-truncated block, keep images
597
+ // Truncate: middle elision when a head budget is configured, otherwise tail-only.
598
+ const useMiddle = headBytes > 0;
599
+ const truncated = useMiddle
600
+ ? truncateMiddle(fullText, {
601
+ maxBytes: headBytes + tailBytes,
602
+ maxLines: tailLines * 2,
603
+ maxHeadBytes: headBytes,
604
+ maxHeadLines: tailLines,
605
+ })
606
+ : truncateTail(fullText, {
607
+ maxBytes: tailBytes,
608
+ maxLines: tailLines,
609
+ });
610
+
611
+ // Replace text blocks with single truncated block, keep images
499
612
  const newContent: (TextContent | ImageContent)[] = [];
500
613
  for (const block of result.content) {
501
614
  if (block.type !== "text") {
@@ -507,18 +620,44 @@ async function spillLargeResultToArtifact(
507
620
  // Build truncation meta
508
621
  const outputLines = truncated.outputLines ?? truncated.totalLines;
509
622
  const outputBytes = truncated.outputBytes ?? truncated.totalBytes;
510
- const shownStart = truncated.totalLines - outputLines + 1;
511
- const truncationMeta: TruncationMeta = {
512
- direction: "tail",
513
- truncatedBy: truncated.truncatedBy ?? "bytes",
514
- totalLines: truncated.totalLines,
515
- totalBytes: truncated.totalBytes,
516
- outputLines,
517
- outputBytes,
518
- maxBytes: tailBytes,
519
- shownRange: { start: shownStart, end: truncated.totalLines },
520
- artifactId,
521
- };
623
+ let truncationMeta: TruncationMeta;
624
+ if (truncated.truncatedBy === "middle") {
625
+ const elidedLines = truncated.elidedLines ?? Math.max(0, truncated.totalLines - outputLines);
626
+ const elidedBytes = truncated.elidedBytes ?? Math.max(0, truncated.totalBytes - outputBytes);
627
+ const keptLines = Math.max(0, outputLines - 1); // -1 for marker line
628
+ const headLines = Math.ceil(keptLines / 2);
629
+ const tailLineCount = keptLines - headLines;
630
+ truncationMeta = {
631
+ direction: "middle",
632
+ truncatedBy: "middle",
633
+ totalLines: truncated.totalLines,
634
+ totalBytes: truncated.totalBytes,
635
+ outputLines,
636
+ outputBytes,
637
+ maxBytes: headBytes + tailBytes,
638
+ headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
639
+ tailRange:
640
+ tailLineCount > 0
641
+ ? { start: truncated.totalLines - tailLineCount + 1, end: truncated.totalLines }
642
+ : undefined,
643
+ elidedLines,
644
+ elidedBytes,
645
+ artifactId,
646
+ };
647
+ } else {
648
+ const shownStart = truncated.totalLines - outputLines + 1;
649
+ truncationMeta = {
650
+ direction: "tail",
651
+ truncatedBy: truncated.truncatedBy ?? "bytes",
652
+ totalLines: truncated.totalLines,
653
+ totalBytes: truncated.totalBytes,
654
+ outputLines,
655
+ outputBytes,
656
+ maxBytes: tailBytes,
657
+ shownRange: { start: shownStart, end: truncated.totalLines },
658
+ artifactId,
659
+ };
660
+ }
522
661
 
523
662
  const newMeta: OutputMeta = { ...(existingMeta ?? {}), truncation: truncationMeta };
524
663
  const newDetails = { ...(result.details ?? {}), meta: newMeta };
@@ -3,10 +3,12 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
+ import { InternalUrlRouter } from "../internal-urls";
7
+ import { ToolError } from "./tool-errors";
6
8
 
7
9
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
8
- const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw|conflicts)$/i;
9
- const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+)?$/i;
10
+ const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?(?:,L?\d+(?:[-+]L?\d+)?)*|raw|conflicts)$/i;
11
+ const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+)?(?:,L?\d+(?:[-+]L?\d+)?)*$/i;
10
12
  const FILE_RAW_ONLY_RE = /^raw$/i;
11
13
  const NARROW_NO_BREAK_SPACE = "\u202F";
12
14
  const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
@@ -567,3 +569,124 @@ export function resolveReadPath(filePath: string, cwd: string): string {
567
569
 
568
570
  return resolved;
569
571
  }
572
+
573
+ // =============================================================================
574
+ // Tool-scope resolution (search/ast tools)
575
+ // =============================================================================
576
+
577
+ export interface ToolScopeOptions {
578
+ rawPaths: string[];
579
+ cwd: string;
580
+ /** Verb used in the "Cannot {action} internal URL without a backing file: …" message. */
581
+ internalUrlAction: string;
582
+ /** Collect absolute paths flagged immutable by their internal-URL handler. */
583
+ trackImmutableSources?: boolean;
584
+ /** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
585
+ surfaceExactFilePaths?: boolean;
586
+ /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
587
+ multipathStatHint?: string;
588
+ }
589
+
590
+ export interface ToolScopeResolution {
591
+ searchPath: string;
592
+ scopePath: string;
593
+ globFilter: string | undefined;
594
+ isDirectory: boolean;
595
+ multiTargets?: ResolvedSearchTarget[];
596
+ exactFilePaths?: string[];
597
+ missingPaths: string[];
598
+ immutableSourcePaths: Set<string>;
599
+ }
600
+
601
+ /**
602
+ * Shared path-input pipeline for `search`, `ast_grep`, and `ast_edit`:
603
+ * 1. normalize + reject empty paths,
604
+ * 2. resolve internal URLs through {@link InternalUrlRouter} to backing files,
605
+ * 3. partition existing vs missing when multiple paths are supplied,
606
+ * 4. derive a single search base path / glob, or a multi-target list,
607
+ * 5. stat the resolved base path so callers can branch on directory vs file scope.
608
+ */
609
+ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
610
+ const { rawPaths: inputs, cwd, internalUrlAction } = opts;
611
+ const rawPaths = inputs.map(normalizePathLikeInput);
612
+ if (rawPaths.some(rawPath => rawPath.length === 0)) {
613
+ throw new ToolError("`paths` must contain non-empty paths or globs");
614
+ }
615
+ const internalRouter = InternalUrlRouter.instance();
616
+ const resolvedPathInputs: string[] = [];
617
+ const immutableSourcePaths = new Set<string>();
618
+ for (const rawPath of rawPaths) {
619
+ if (!internalRouter.canHandle(rawPath)) {
620
+ resolvedPathInputs.push(rawPath);
621
+ continue;
622
+ }
623
+ if (hasGlobPathChars(rawPath)) {
624
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
625
+ }
626
+ const resource = await internalRouter.resolve(rawPath);
627
+ if (!resource.sourcePath) {
628
+ throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
629
+ }
630
+ if (opts.trackImmutableSources && resource.immutable) {
631
+ immutableSourcePaths.add(path.resolve(resource.sourcePath));
632
+ }
633
+ resolvedPathInputs.push(resource.sourcePath);
634
+ }
635
+
636
+ let missingPaths: string[] = [];
637
+ let effectivePaths = resolvedPathInputs;
638
+ if (resolvedPathInputs.length > 1) {
639
+ const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath);
640
+ if (partition.valid.length === 0) {
641
+ throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
642
+ }
643
+ effectivePaths = partition.valid;
644
+ missingPaths = partition.missing;
645
+ }
646
+
647
+ let searchPath: string;
648
+ let scopePath: string;
649
+ let globFilter: string | undefined;
650
+ let multiTargets: ResolvedSearchTarget[] | undefined;
651
+ let exactFilePaths: string[] | undefined;
652
+ if (effectivePaths.length === 1) {
653
+ const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
654
+ searchPath = resolveToCwd(parsedPath.basePath, cwd);
655
+ globFilter = parsedPath.glob;
656
+ scopePath = formatPathRelativeToCwd(searchPath, cwd);
657
+ } else {
658
+ const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd);
659
+ if (!multiSearchPath) {
660
+ throw new ToolError("`paths` must contain at least one path or glob");
661
+ }
662
+ searchPath = multiSearchPath.basePath;
663
+ multiTargets = multiSearchPath.targets;
664
+ if (opts.surfaceExactFilePaths) {
665
+ exactFilePaths = multiSearchPath.exactFilePaths;
666
+ globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
667
+ } else {
668
+ globFilter = multiTargets ? undefined : multiSearchPath.glob;
669
+ }
670
+ scopePath = multiSearchPath.scopePath;
671
+ }
672
+
673
+ let isDirectory: boolean;
674
+ try {
675
+ const stat = await Bun.file(searchPath).stat();
676
+ isDirectory = stat.isDirectory();
677
+ } catch {
678
+ const hint = opts.multipathStatHint && rawPaths.length > 1 ? opts.multipathStatHint : "";
679
+ throw new ToolError(`Path not found: ${scopePath}${hint}`);
680
+ }
681
+
682
+ return {
683
+ searchPath,
684
+ scopePath,
685
+ globFilter,
686
+ isDirectory,
687
+ multiTargets,
688
+ exactFilePaths,
689
+ missingPaths,
690
+ immutableSourcePaths,
691
+ };
692
+ }