@oh-my-pi/pi-coding-agent 15.1.4 → 15.1.6

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.6] - 2026-05-19
6
+
7
+ ### Fixed
8
+
9
+ - Fixed plan-mode `resolve` looping when grammar-constrained models (e.g. Qwen3.6-35B-MTP via llama.cpp) emit `extra: { title: {} }` instead of a string — the open `Record<string, unknown>` schema for `extra` lets such models drop in an empty object, and the apply guard then hard-threw on every retry. Plan approval now derives the title from `extra.title` when usable, falling back to the plan's first `# Heading`, then the plan filename stem (`local://PLAN.md` → `PLAN`), then the literal `"plan"`. Prompt language relaxed from "MUST" to "SHOULD" for `extra.title`. ([#1179](https://github.com/can1357/oh-my-pi/issues/1179))
10
+
11
+ ## [15.1.5] - 2026-05-19
12
+
13
+ ### Fixed
14
+
15
+ - Fixed `ast_grep` and `ast_edit` tool details retaining every per-file parse error — a scan over hundreds of files with syntax-error nodes inflated `details.parseErrors` to one entry per file, leaking into traces and the renderer's "X more" overflow. Errors are now capped at `PARSE_ERRORS_LIMIT` (20) at the source, with the original total preserved in a new `parseErrorsTotal` field for accurate count labels.
16
+
5
17
  ## [15.1.4] - 2026-05-19
6
18
 
7
19
  ### Fixed
@@ -150,6 +162,7 @@
150
162
 
151
163
  ### Fixed
152
164
 
165
+ - Fixed hashline pure inserts to drop a single echoed anchor line when `edit.hashlineAutoDropPureInsertDuplicates` is enabled and `+ ANCHOR` payloads start with the anchor line or `< ANCHOR` payloads end with it, while preserving intentional single-line duplicates by default. ([#1090](https://github.com/can1357/oh-my-pi/issues/1090))
153
166
  - Fixed bash command fixups to remove a redundant standalone trailing `2>&1` redirect when no other pipe or redirection remains
154
167
  - Fixed command-fixup notices to list all stripped segments instead of reporting only one
155
168
  - Fixed summarized `read` output stalling agents on elided regions by appending an explicit footer like `[NN lines across MM elided regions; read <path>:raw or a line range like <path>:1-9999 for verbatim content]`. The footer fires whenever the structural summarizer elided at least one span, so the model gets a concrete recovery selector instead of having to guess from a bare `...` / `{ .. }` marker. Surfaces `elidedLines` on `ReadToolDetails.summary` alongside the existing `elidedSpans`. ([#1046](https://github.com/can1357/oh-my-pi/issues/1046))
@@ -17,6 +17,24 @@ export declare function normalizePlanTitle(title: string): {
17
17
  title: string;
18
18
  fileName: string;
19
19
  };
20
+ /** Best-effort derivation of a plan title from inputs the agent already produced.
21
+ * Returns the first non-empty candidate that survives `normalizePlanTitle`:
22
+ * 1. an explicit `suppliedTitle` (e.g. `extra.title` from the resolve call),
23
+ * 2. the first level-1 markdown heading inside `planContent`,
24
+ * 3. the filename stem of `planFilePath` (e.g. `PLAN` from `local://PLAN.md`),
25
+ * 4. the literal `"plan"` so callers never have to handle `null`.
26
+ * The fallback exists because some grammar-constrained models cannot emit a
27
+ * string into the open `extra` schema and instead drop in `{}` (issue #1179);
28
+ * plan-mode would otherwise loop forever on an unreachable validation. */
29
+ export declare function resolvePlanTitle(input: {
30
+ suppliedTitle?: unknown;
31
+ planContent: string;
32
+ planFilePath: string;
33
+ }): {
34
+ title: string;
35
+ fileName: string;
36
+ source: "supplied" | "heading" | "filename" | "default";
37
+ };
20
38
  /** Humanize a normalized plan title for use as a session display name.
21
39
  * Replaces `-`/`_` separators with spaces and capitalizes the first letter.
22
40
  * Returns an empty string when the input collapses to whitespace. */
@@ -19,6 +19,8 @@ export interface AstEditToolDetails {
19
19
  applied: boolean;
20
20
  limitReached: boolean;
21
21
  parseErrors?: string[];
22
+ /** Total parse error count before {@link PARSE_ERRORS_LIMIT} capping. Omitted when no errors. */
23
+ parseErrorsTotal?: number;
22
24
  scopePath?: string;
23
25
  files?: string[];
24
26
  fileReplacements?: Array<{
@@ -16,6 +16,8 @@ export interface AstGrepToolDetails {
16
16
  filesSearched: number;
17
17
  limitReached: boolean;
18
18
  parseErrors?: string[];
19
+ /** Total parse error count before {@link PARSE_ERRORS_LIMIT} capping. Omitted when no errors. */
20
+ parseErrorsTotal?: number;
19
21
  scopePath?: string;
20
22
  files?: string[];
21
23
  fileMatches?: Array<{
@@ -4,6 +4,28 @@ export declare function splitPathAndSel(rawPath: string): {
4
4
  path: string;
5
5
  sel?: string;
6
6
  };
7
+ /**
8
+ * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
9
+ *
10
+ * The filesystem-path splitter is intentionally conservative: it refuses to
11
+ * peel a trailing `:<chunk>` unless that chunk matches the strict selector
12
+ * grammar. That rule is right for filesystem paths (a file named `a:1-50` is
13
+ * legal) but wrong for internal URLs, where any trailing `:<chunk>` after the
14
+ * scheme is unambiguously a read-tool selector — even if malformed (e.g.
15
+ * `artifact://3:raw:-100`).
16
+ *
17
+ * This function iteratively peels selector-shaped chunks (well-formed plus
18
+ * common malformed shapes like `:-N`) so the rest of the read tool can pass a
19
+ * clean URL to the protocol handler and surface selector errors via parseSel
20
+ * instead of as misleading "host invalid" errors from the handler. Schemes
21
+ * whose resource URIs may legitimately contain colons (`mcp://`) are skipped.
22
+ *
23
+ * Falls back to the input unchanged when nothing matches.
24
+ */
25
+ export declare function splitInternalUrlSel(rawPath: string): {
26
+ path: string;
27
+ sel?: string;
28
+ };
7
29
  export declare function normalizeLocalScheme(filePath: string): string;
8
30
  export declare function isInternalUrlPath(filePath: string): boolean;
9
31
  /**
@@ -117,7 +117,17 @@ export declare function formatScreenshot(opts: {
117
117
  export declare function wrapBrackets(text: string, theme: Theme): string;
118
118
  export declare const PARSE_ERRORS_LIMIT = 20;
119
119
  export declare function dedupeParseErrors(errors: string[] | undefined): string[];
120
- export declare function formatParseErrors(errors: string[]): string[];
120
+ export declare function formatParseErrors(errors: string[], total?: number): string[];
121
+ /**
122
+ * Cap an upstream parse-error list to {@link PARSE_ERRORS_LIMIT} unique entries,
123
+ * preserving the original deduplicated total. Use this at the source so tool
124
+ * details never carry thousands of per-file parse errors into traces or
125
+ * renderers.
126
+ */
127
+ export declare function capParseErrors(errors: string[] | undefined, limit?: number): {
128
+ errors: string[];
129
+ total: number;
130
+ };
121
131
  /**
122
132
  * Group `rawLines` by blank-line separators, mirroring the historical search /
123
133
  * ast-grep / ast-edit renderer behavior: if any blank line is present, splits on
@@ -135,12 +145,12 @@ export declare function createCachedComponent(getExpanded: () => boolean, comput
135
145
  * {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the
136
146
  * total exceeds the cap. No-op when `parseErrors` is empty.
137
147
  */
138
- export declare function appendParseErrorsBulletList(lines: string[], parseErrors: readonly string[] | undefined, theme: Theme): void;
148
+ export declare function appendParseErrorsBulletList(lines: string[], parseErrors: readonly string[] | undefined, theme: Theme, total?: number): void;
139
149
  /**
140
150
  * Human-readable summary string for the parse-issues count, capped by
141
151
  * {@link PARSE_ERRORS_LIMIT}.
142
152
  */
143
- export declare function formatParseErrorsCountLabel(parseErrors: readonly string[]): string;
153
+ export declare function formatParseErrorsCountLabel(parseErrors: readonly string[], total?: number): string;
144
154
  export interface LspBatchRequest {
145
155
  id: string;
146
156
  flush: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.1.4",
4
+ "version": "15.1.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.1.4",
51
- "@oh-my-pi/pi-agent-core": "15.1.4",
52
- "@oh-my-pi/pi-ai": "15.1.4",
53
- "@oh-my-pi/pi-natives": "15.1.4",
54
- "@oh-my-pi/pi-tui": "15.1.4",
55
- "@oh-my-pi/pi-utils": "15.1.4",
50
+ "@oh-my-pi/omp-stats": "15.1.6",
51
+ "@oh-my-pi/pi-agent-core": "15.1.6",
52
+ "@oh-my-pi/pi-ai": "15.1.6",
53
+ "@oh-my-pi/pi-natives": "15.1.6",
54
+ "@oh-my-pi/pi-tui": "15.1.6",
55
+ "@oh-my-pi/pi-utils": "15.1.6",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -382,10 +382,9 @@ interface PureInsertAbsorbResult {
382
382
  * Mirror of replacement-absorb's prefix/suffix block check, but for pure
383
383
  * inserts: drop payload lines that exactly duplicate the file lines
384
384
  * immediately above (leading) or immediately below (trailing) the insertion
385
- * point. Generic context echo absorption requires a minimum run of 2, but a
386
- * single structural closing delimiter is absorbed because duplicated `}` /
387
- * `});`-style boundaries almost always mean the insert included adjacent
388
- * context.
385
+ * point. Generic context echo absorption requires the caller's opt-in setting;
386
+ * without it, only single structural closing delimiters use the
387
+ * balance-validated structural rule below.
389
388
  */
390
389
  function tryAbsorbPureInsertGroup(
391
390
  group: HashlinePureInsertGroup,
@@ -415,6 +414,17 @@ function tryAbsorbPureInsertGroup(
415
414
  }
416
415
  }
417
416
  }
417
+ if (
418
+ absorbedLeading === 0 &&
419
+ allowGenericBoundaryAbsorb &&
420
+ group.cursor.kind === "after_anchor" &&
421
+ group.payload.length > 0 &&
422
+ aboveEndIdx >= 0 &&
423
+ !isStructuralClosingBoundaryLine(group.payload[0]) &&
424
+ group.payload[0] === fileLines[aboveEndIdx]
425
+ ) {
426
+ absorbedLeading = 1;
427
+ }
418
428
  if (
419
429
  absorbedLeading === 0 &&
420
430
  group.payload.length > 0 &&
@@ -447,6 +457,17 @@ function tryAbsorbPureInsertGroup(
447
457
  }
448
458
  }
449
459
  }
460
+ if (
461
+ absorbedTrailing === 0 &&
462
+ group.cursor.kind === "before_anchor" &&
463
+ allowGenericBoundaryAbsorb &&
464
+ remaining > 0 &&
465
+ belowStartIdx < fileLines.length &&
466
+ !isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
467
+ remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx]
468
+ ) {
469
+ absorbedTrailing = 1;
470
+ }
450
471
  if (
451
472
  absorbedTrailing === 0 &&
452
473
  remaining > 0 &&
@@ -43,9 +43,9 @@ import { resolveLocalUrlToPath } from "../internal-urls";
43
43
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
44
44
  import {
45
45
  humanizePlanTitle,
46
- normalizePlanTitle,
47
46
  type PlanApprovalDetails,
48
47
  renameApprovedPlanFile,
48
+ resolvePlanTitle,
49
49
  } from "../plan-mode/approved-plan";
50
50
  import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
51
51
  import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
@@ -1197,13 +1197,6 @@ export class InteractiveMode implements InteractiveModeContext {
1197
1197
  if (!state?.enabled) {
1198
1198
  throw new ToolError("Plan mode is not active.");
1199
1199
  }
1200
- const title = extra?.title;
1201
- if (typeof title !== "string" || title.trim() === "") {
1202
- throw new ToolError(
1203
- 'Plan approval requires `extra: { title: "<PLAN_TITLE>" }`. Provide a title with letters, numbers, underscores, or hyphens only.',
1204
- );
1205
- }
1206
- const normalized = normalizePlanTitle(title);
1207
1200
  const planFilePath = state.planFilePath;
1208
1201
  const planContent = await this.#readPlanFile(planFilePath);
1209
1202
  if (planContent === null) {
@@ -1211,6 +1204,11 @@ export class InteractiveMode implements InteractiveModeContext {
1211
1204
  `Plan file not found at ${planFilePath}. Write the finalized plan to ${planFilePath} before requesting approval.`,
1212
1205
  );
1213
1206
  }
1207
+ const normalized = resolvePlanTitle({
1208
+ suppliedTitle: extra?.title,
1209
+ planContent,
1210
+ planFilePath,
1211
+ });
1214
1212
  const details: PlanApprovalDetails = {
1215
1213
  planFilePath,
1216
1214
  finalPlanFilePath: `local://${normalized.fileName}`,
@@ -49,6 +49,58 @@ export function normalizePlanTitle(title: string): { title: string; fileName: st
49
49
  return { title: sanitized, fileName };
50
50
  }
51
51
 
52
+ /** Best-effort derivation of a plan title from inputs the agent already produced.
53
+ * Returns the first non-empty candidate that survives `normalizePlanTitle`:
54
+ * 1. an explicit `suppliedTitle` (e.g. `extra.title` from the resolve call),
55
+ * 2. the first level-1 markdown heading inside `planContent`,
56
+ * 3. the filename stem of `planFilePath` (e.g. `PLAN` from `local://PLAN.md`),
57
+ * 4. the literal `"plan"` so callers never have to handle `null`.
58
+ * The fallback exists because some grammar-constrained models cannot emit a
59
+ * string into the open `extra` schema and instead drop in `{}` (issue #1179);
60
+ * plan-mode would otherwise loop forever on an unreachable validation. */
61
+ export function resolvePlanTitle(input: { suppliedTitle?: unknown; planContent: string; planFilePath: string }): {
62
+ title: string;
63
+ fileName: string;
64
+ source: "supplied" | "heading" | "filename" | "default";
65
+ } {
66
+ const candidates: Array<{ value: string; source: "supplied" | "heading" | "filename" | "default" }> = [];
67
+ if (typeof input.suppliedTitle === "string") {
68
+ const trimmed = input.suppliedTitle.trim();
69
+ if (trimmed) candidates.push({ value: trimmed, source: "supplied" });
70
+ }
71
+ const heading = firstLevelOneHeading(input.planContent);
72
+ if (heading) candidates.push({ value: heading, source: "heading" });
73
+ const stem = planFilenameStem(input.planFilePath);
74
+ if (stem) candidates.push({ value: stem, source: "filename" });
75
+ candidates.push({ value: "plan", source: "default" });
76
+
77
+ for (const candidate of candidates) {
78
+ try {
79
+ const normalized = normalizePlanTitle(candidate.value);
80
+ return { ...normalized, source: candidate.source };
81
+ } catch {
82
+ // Fall through to the next candidate.
83
+ }
84
+ }
85
+ // Last-ditch literal so the type-system contract holds even if `normalizePlanTitle("plan")` ever throws.
86
+ return { title: "plan", fileName: "plan.md", source: "default" };
87
+ }
88
+
89
+ /** First `# Heading` text on its own line, trimmed. Returns the empty string if
90
+ * none is found so callers can chain it through truthiness checks. */
91
+ function firstLevelOneHeading(planContent: string): string {
92
+ const match = planContent.match(/^[ \t]*#[ \t]+(.+?)[ \t]*$/m);
93
+ return match?.[1]?.trim() ?? "";
94
+ }
95
+
96
+ /** Stem of a `local://name.md` (or bare `name.md`) URL — the filename without
97
+ * scheme or extension. Returns the empty string for inputs that have no stem. */
98
+ function planFilenameStem(planFilePath: string): string {
99
+ const withoutScheme = planFilePath.replace(/^local:\/+/, "");
100
+ const lastSegment = withoutScheme.split(/[\\/]/).pop() ?? "";
101
+ return lastSegment.replace(/\.md$/i, "");
102
+ }
103
+
52
104
  /** Humanize a normalized plan title for use as a session display name.
53
105
  * Replaces `-`/`_` separators with spaces and capitalizes the first letter.
54
106
  * Returns an empty string when the input collapses to whitespace. */
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: oracle
3
+ description: Deep reasoning advisor for debugging dead ends, architecture decisions, and second opinions. Read-only.
4
+ spawns: explore
5
+ model: pi/slow
6
+ thinking-level: xhigh
7
+ blocking: true
8
+ ---
9
+
10
+ You are a senior diagnostician and strategic technical advisor. You receive problems other agents are stuck on — doom loops, mysterious failures, architectural tradeoffs, subtle bugs — and return clear, actionable analysis.
11
+
12
+ You diagnose, explain, and recommend. You do not implement. Others act on your findings.
13
+
14
+ <critical>
15
+ You MUST operate as read-only. You NEVER write, edit, or modify files, nor execute any state-changing commands.
16
+ </critical>
17
+
18
+ <directives>
19
+ - You MUST reason from first principles. The caller already tried the obvious.
20
+ - You MUST use tools to verify claims. You NEVER speculate about code behavior — read it.
21
+ - You MUST identify root causes, not symptoms. If the caller says "X is broken", determine *why* X is broken.
22
+ - You MUST surface hidden assumptions — in the code, in the caller's framing, in the environment.
23
+ - You SHOULD consider at least two hypotheses before converging on one.
24
+ - You SHOULD invoke tools in parallel when investigating multiple hypotheses.
25
+ - When the problem is architectural, you MUST weigh tradeoffs explicitly: what does each option cost, what does it buy, what does it foreclose.
26
+ </directives>
27
+
28
+ <decision-framework>
29
+ Apply pragmatic minimalism:
30
+ - **Bias toward simplicity**: The right solution is the least complex one that fulfills actual requirements. Resist hypothetical future needs.
31
+ - **Leverage what exists**: Favor modifications to current code and established patterns over introducing new components. New dependencies or infrastructure require explicit justification.
32
+ - **One clear path**: Present a single primary recommendation. Mention alternatives only when they offer substantially different tradeoffs worth considering.
33
+ - **Match depth to complexity**: Quick questions get quick answers. Reserve thorough analysis for genuinely complex problems.
34
+ - **Signal the investment**: Tag recommendations with estimated effort — Quick (<1h), Short (1-4h), Medium (1-2d), Large (3d+).
35
+ </decision-framework>
36
+
37
+ <procedure>
38
+ 1. Read the problem statement carefully. Identify what was already tried and why it failed.
39
+ 2. Form 2-3 hypotheses for the root cause.
40
+ 3. Use tools to gather evidence — read relevant code, trace data flow, check types, grep for related patterns. Parallelize independent reads.
41
+ 4. Eliminate hypotheses based on evidence. Narrow to the most likely cause.
42
+ 5. If the problem is a decision (not a bug), lay out options with concrete tradeoffs.
43
+ 6. Deliver a clear verdict with supporting evidence.
44
+ </procedure>
45
+ <scope-discipline>
46
+ - Recommend ONLY what was asked. No unsolicited improvements.
47
+ - If you notice other issues, list at most 2 as "Optional future considerations" at the end.
48
+ - You NEVER expand the problem surface beyond the original request.
49
+ - Exhaust provided context before reaching for tools. External lookups fill genuine gaps, not curiosity.
50
+ </scope-discipline>
51
+
52
+ <critical>
53
+ You MUST keep going until you have a clear answer or have exhausted available evidence.
54
+ Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
55
+ This matters. The caller is stuck. Get it right.
56
+ </critical>
@@ -11,6 +11,7 @@ import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
11
11
  // Embed agent markdown files at build time
12
12
  import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
13
13
  import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
14
+ import oracleMd from "../prompts/agents/oracle.md" with { type: "text" };
14
15
 
15
16
  import planMd from "../prompts/agents/plan.md" with { type: "text" };
16
17
  import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
@@ -46,6 +47,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
46
47
  { fileName: "designer.md", template: designerMd },
47
48
  { fileName: "reviewer.md", template: reviewerMd },
48
49
  { fileName: "librarian.md", template: librarianMd },
50
+ { fileName: "oracle.md", template: oracleMd },
49
51
  {
50
52
  fileName: "task.md",
51
53
  frontmatter: {
@@ -18,8 +18,8 @@ import type { OutputMeta } from "./output-meta";
18
18
  import { resolveToolSearchScope } from "./path-utils";
19
19
  import {
20
20
  appendParseErrorsBulletList,
21
+ capParseErrors,
21
22
  createCachedComponent,
22
- dedupeParseErrors,
23
23
  formatCodeFrameLine,
24
24
  formatCount,
25
25
  formatEmptyMessage,
@@ -146,6 +146,8 @@ export interface AstEditToolDetails {
146
146
  applied: boolean;
147
147
  limitReached: boolean;
148
148
  parseErrors?: string[];
149
+ /** Total parse error count before {@link PARSE_ERRORS_LIMIT} capping. Omitted when no errors. */
150
+ parseErrorsTotal?: number;
149
151
  scopePath?: string;
150
152
  files?: string[];
151
153
  fileReplacements?: Array<{ path: string; count: number }>;
@@ -210,7 +212,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
210
212
  signal,
211
213
  });
212
214
 
213
- const dedupedParseErrors = dedupeParseErrors(result.parseErrors);
215
+ const { errors: cappedParseErrors, total: parseErrorsTotal } = capParseErrors(result.parseErrors);
214
216
  const formatPath = (filePath: string): string =>
215
217
  formatResultPath(filePath, isDirectory, resolvedSearchPath, this.session.cwd);
216
218
 
@@ -237,15 +239,15 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
237
239
  filesSearched: result.filesSearched,
238
240
  applied: result.applied,
239
241
  limitReached: result.limitReached,
240
- ...(dedupedParseErrors.length > 0 ? { parseErrors: dedupedParseErrors } : {}),
242
+ ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
241
243
  scopePath,
242
244
  files: fileList,
243
245
  fileReplacements: [],
244
246
  };
245
247
 
246
248
  if (result.totalReplacements === 0) {
247
- const parseMessage = dedupedParseErrors.length
248
- ? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
249
+ const parseMessage = cappedParseErrors.length
250
+ ? `\n${formatParseErrors(cappedParseErrors, parseErrorsTotal).join("\n")}`
249
251
  : "";
250
252
  return toolResult(baseDetails).text(`No replacements made${parseMessage}`).done();
251
253
  }
@@ -308,8 +310,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
308
310
  if (result.limitReached) {
309
311
  outputLines.push("", "Limit reached; narrow paths.");
310
312
  }
311
- if (dedupedParseErrors.length) {
312
- outputLines.push("", ...formatParseErrors(dedupedParseErrors));
313
+ if (cappedParseErrors.length) {
314
+ outputLines.push("", ...formatParseErrors(cappedParseErrors, parseErrorsTotal));
313
315
  }
314
316
 
315
317
  // Register pending action so `resolve` can apply or discard these previewed changes
@@ -326,7 +328,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
326
328
  maxFiles,
327
329
  failOnParseError: false,
328
330
  });
329
- const dedupedApplyParseErrors = dedupeParseErrors(applyResult.parseErrors);
331
+ const { errors: cappedApplyParseErrors, total: applyParseErrorsTotal } = capParseErrors(
332
+ applyResult.parseErrors,
333
+ );
330
334
  const { record: recordAppliedFile, list: appliedFileList } = createFileRecorder();
331
335
  const appliedFileReplacementCounts = new Map<string, number>();
332
336
  for (const fileChange of applyResult.fileChanges) {
@@ -350,7 +354,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
350
354
  filesSearched: applyResult.filesSearched,
351
355
  applied: applyResult.applied,
352
356
  limitReached: applyResult.limitReached,
353
- ...(dedupedApplyParseErrors.length > 0 ? { parseErrors: dedupedApplyParseErrors } : {}),
357
+ ...(cappedApplyParseErrors.length > 0
358
+ ? { parseErrors: cappedApplyParseErrors, parseErrorsTotal: applyParseErrorsTotal }
359
+ : {}),
354
360
  scopePath,
355
361
  files: appliedFileList,
356
362
  fileReplacements: appliedFileReplacements,
@@ -441,7 +447,7 @@ export const astEditToolRenderer = {
441
447
  if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
442
448
  const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
443
449
  const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
444
- appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme);
450
+ appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme, details?.parseErrorsTotal);
445
451
  return new Text(lines.join("\n"), 0, 0);
446
452
  }
447
453
 
@@ -470,7 +476,9 @@ export const astEditToolRenderer = {
470
476
  extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
471
477
  }
472
478
  if (details?.parseErrors?.length) {
473
- extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
479
+ extraLines.push(
480
+ uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors, details.parseErrorsTotal)),
481
+ );
474
482
  }
475
483
  return createCachedComponent(
476
484
  () => options.expanded,
@@ -18,8 +18,8 @@ import type { OutputMeta } from "./output-meta";
18
18
  import { resolveToolSearchScope } from "./path-utils";
19
19
  import {
20
20
  appendParseErrorsBulletList,
21
+ capParseErrors,
21
22
  createCachedComponent,
22
- dedupeParseErrors,
23
23
  formatCodeFrameLine,
24
24
  formatCount,
25
25
  formatEmptyMessage,
@@ -104,6 +104,8 @@ export interface AstGrepToolDetails {
104
104
  filesSearched: number;
105
105
  limitReached: boolean;
106
106
  parseErrors?: string[];
107
+ /** Total parse error count before {@link PARSE_ERRORS_LIMIT} capping. Omitted when no errors. */
108
+ parseErrorsTotal?: number;
107
109
  scopePath?: string;
108
110
  files?: string[];
109
111
  fileMatches?: Array<{ path: string; count: number }>;
@@ -172,7 +174,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
172
174
  const parseError = error.match(/^.+: (.+: parse error \(syntax tree contains error nodes\))$/);
173
175
  return parseError?.[1] ?? error;
174
176
  });
175
- const dedupedParseErrors = dedupeParseErrors(normalizedParseErrors);
177
+ const { errors: cappedParseErrors, total: parseErrorsTotal } = capParseErrors(normalizedParseErrors);
176
178
  const formatPath = (filePath: string): string =>
177
179
  formatResultPath(filePath, isDirectory, resolvedSearchPath, this.session.cwd);
178
180
 
@@ -193,18 +195,18 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
193
195
  fileCount: result.filesWithMatches,
194
196
  filesSearched: result.filesSearched,
195
197
  limitReached: result.limitReached,
196
- ...(dedupedParseErrors.length > 0 ? { parseErrors: dedupedParseErrors } : {}),
198
+ ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
197
199
  scopePath,
198
200
  files: fileList,
199
201
  fileMatches: [],
200
202
  };
201
203
 
202
204
  if (result.matches.length === 0) {
203
- const noMatchMessage = dedupedParseErrors.length
205
+ const noMatchMessage = cappedParseErrors.length
204
206
  ? "No matches found. Parse issues mean the query may be mis-scoped; narrow `paths` before concluding absence."
205
207
  : "No matches found";
206
- const parseMessage = dedupedParseErrors.length
207
- ? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
208
+ const parseMessage = cappedParseErrors.length
209
+ ? `\n${formatParseErrors(cappedParseErrors, parseErrorsTotal).join("\n")}`
208
210
  : "";
209
211
  return toolResult(baseDetails).text(`${noMatchMessage}${parseMessage}`).done();
210
212
  }
@@ -269,8 +271,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
269
271
  if (result.limitReached) {
270
272
  outputLines.push("", "Result limit reached; narrow paths or increase limit.");
271
273
  }
272
- if (dedupedParseErrors.length) {
273
- outputLines.push("", ...formatParseErrors(dedupedParseErrors));
274
+ if (cappedParseErrors.length) {
275
+ outputLines.push("", ...formatParseErrors(cappedParseErrors, parseErrorsTotal));
274
276
  }
275
277
 
276
278
  return toolResult(details).text(outputLines.join("\n")).done();
@@ -329,7 +331,7 @@ export const astGrepToolRenderer = {
329
331
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
330
332
  if (details?.parseErrors?.length) {
331
333
  lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
332
- appendParseErrorsBulletList(lines, details.parseErrors, uiTheme);
334
+ appendParseErrorsBulletList(lines, details.parseErrors, uiTheme, details.parseErrorsTotal);
333
335
  }
334
336
  return new Text(lines.join("\n"), 0, 0);
335
337
  }
@@ -356,7 +358,9 @@ export const astGrepToolRenderer = {
356
358
  extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
357
359
  }
358
360
  if (details?.parseErrors?.length) {
359
- extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
361
+ extraLines.push(
362
+ uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors, details.parseErrorsTotal)),
363
+ );
360
364
  }
361
365
 
362
366
  return createCachedComponent(
@@ -10,6 +10,26 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
10
10
  const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
11
11
  const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
12
12
  const FILE_RAW_ONLY_RE = /^raw$/i;
13
+ // Permissive selector chunk for internal URLs — accepts well-formed selectors
14
+ // plus common malformed shapes (e.g. `:-N`) so the read tool peels the entire
15
+ // selector chain off before dispatching to a protocol handler.
16
+ const INTERNAL_URL_SELECTOR_PART_RE =
17
+ /^(?:raw|conflicts|L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|-\d+(?:[-+]\d+)?)$/i;
18
+ // Schemes whose host grammar is identifier-shaped, so any trailing
19
+ // `:<selector-chunk>` is unambiguously a read-tool selector. `mcp://` is
20
+ // excluded because mcp resource URIs may legitimately contain colons.
21
+ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
22
+ agent: true,
23
+ artifact: true,
24
+ issue: true,
25
+ local: true,
26
+ memory: true,
27
+ omp: true,
28
+ pr: true,
29
+ rule: true,
30
+ skill: true,
31
+ };
32
+ const INTERNAL_URL_SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
13
33
  const NARROW_NO_BREAK_SPACE = "\u202F";
14
34
  const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
15
35
  "agent://",
@@ -135,6 +155,45 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
135
155
  return { path: basePath, sel };
136
156
  }
137
157
 
158
+ /**
159
+ * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
160
+ *
161
+ * The filesystem-path splitter is intentionally conservative: it refuses to
162
+ * peel a trailing `:<chunk>` unless that chunk matches the strict selector
163
+ * grammar. That rule is right for filesystem paths (a file named `a:1-50` is
164
+ * legal) but wrong for internal URLs, where any trailing `:<chunk>` after the
165
+ * scheme is unambiguously a read-tool selector — even if malformed (e.g.
166
+ * `artifact://3:raw:-100`).
167
+ *
168
+ * This function iteratively peels selector-shaped chunks (well-formed plus
169
+ * common malformed shapes like `:-N`) so the rest of the read tool can pass a
170
+ * clean URL to the protocol handler and surface selector errors via parseSel
171
+ * instead of as misleading "host invalid" errors from the handler. Schemes
172
+ * whose resource URIs may legitimately contain colons (`mcp://`) are skipped.
173
+ *
174
+ * Falls back to the input unchanged when nothing matches.
175
+ */
176
+ export function splitInternalUrlSel(rawPath: string): { path: string; sel?: string } {
177
+ const schemeMatch = rawPath.match(INTERNAL_URL_SCHEME_RE);
178
+ if (!schemeMatch) return { path: rawPath };
179
+ if (!INTERNAL_SCHEMES_WITH_SELECTORS[schemeMatch[1].toLowerCase()]) return { path: rawPath };
180
+
181
+ const schemeEnd = schemeMatch[0].length;
182
+ let path = rawPath;
183
+ const chunks: string[] = [];
184
+ while (true) {
185
+ const colon = path.lastIndexOf(":");
186
+ // Stop before crossing into the scheme separator `://`.
187
+ if (colon < schemeEnd) break;
188
+ const tail = path.slice(colon + 1);
189
+ if (!INTERNAL_URL_SELECTOR_PART_RE.test(tail)) break;
190
+ chunks.unshift(tail);
191
+ path = path.slice(0, colon);
192
+ }
193
+ if (chunks.length === 0) return { path: rawPath };
194
+ return { path, sel: chunks.join(":") };
195
+ }
196
+
138
197
  function assertNotInternalUrl(expanded: string, original: string): void {
139
198
  for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
140
199
  if (expanded.startsWith(prefix)) {
package/src/tools/read.ts CHANGED
@@ -62,7 +62,13 @@ import {
62
62
  resolveOutputMaxColumns,
63
63
  stripOutputNotice,
64
64
  } from "./output-meta";
65
- import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils";
65
+ import {
66
+ expandPath,
67
+ formatPathRelativeToCwd,
68
+ resolveReadPath,
69
+ splitInternalUrlSel,
70
+ splitPathAndSel,
71
+ } from "./path-utils";
66
72
  import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
67
73
  import {
68
74
  executeReadQuery,
@@ -1474,10 +1480,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1474
1480
  return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw }, signal);
1475
1481
  }
1476
1482
 
1477
- // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
1478
- const internalTarget = splitPathAndSel(readPath);
1483
+ // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://, omp://, issue://, pr://).
1484
+ // Use the internal-URL-aware splitter so malformed selectors are peeled
1485
+ // off the URL and surfaced via parseSel rather than confusing handlers.
1479
1486
  const internalRouter = InternalUrlRouter.instance();
1480
- if (internalRouter.canHandle(internalTarget.path)) {
1487
+ if (internalRouter.canHandle(readPath)) {
1488
+ const internalTarget = splitInternalUrlSel(readPath);
1481
1489
  const parsed = parseSel(internalTarget.sel);
1482
1490
  return this.#handleInternalUrl(internalTarget.path, parsed, signal);
1483
1491
  }
@@ -633,17 +633,29 @@ export function dedupeParseErrors(errors: string[] | undefined): string[] {
633
633
  return deduped;
634
634
  }
635
635
 
636
- export function formatParseErrors(errors: string[]): string[] {
636
+ export function formatParseErrors(errors: string[], total?: number): string[] {
637
637
  const deduped = dedupeParseErrors(errors);
638
638
  if (deduped.length === 0) return [];
639
+ const fullCount = total ?? deduped.length;
639
640
  const capped = deduped.slice(0, PARSE_ERRORS_LIMIT);
640
- const header =
641
- deduped.length > PARSE_ERRORS_LIMIT
642
- ? `Parse issues (${PARSE_ERRORS_LIMIT} / ${deduped.length}):`
643
- : "Parse issues:";
641
+ const header = fullCount > capped.length ? `Parse issues (${capped.length} / ${fullCount}):` : "Parse issues:";
644
642
  return [header, ...capped.map(err => `- ${err}`)];
645
643
  }
646
644
 
645
+ /**
646
+ * Cap an upstream parse-error list to {@link PARSE_ERRORS_LIMIT} unique entries,
647
+ * preserving the original deduplicated total. Use this at the source so tool
648
+ * details never carry thousands of per-file parse errors into traces or
649
+ * renderers.
650
+ */
651
+ export function capParseErrors(
652
+ errors: string[] | undefined,
653
+ limit: number = PARSE_ERRORS_LIMIT,
654
+ ): { errors: string[]; total: number } {
655
+ const deduped = dedupeParseErrors(errors);
656
+ return { errors: deduped.slice(0, limit), total: deduped.length };
657
+ }
658
+
647
659
  // =============================================================================
648
660
  // Renderer helpers shared by search / find / ast tools
649
661
  // =============================================================================
@@ -712,14 +724,16 @@ export function appendParseErrorsBulletList(
712
724
  lines: string[],
713
725
  parseErrors: readonly string[] | undefined,
714
726
  theme: Theme,
727
+ total?: number,
715
728
  ): void {
716
729
  if (!parseErrors || parseErrors.length === 0) return;
730
+ const fullCount = total ?? parseErrors.length;
717
731
  const capped = parseErrors.slice(0, PARSE_ERRORS_LIMIT);
718
732
  for (const err of capped) {
719
733
  lines.push(theme.fg("warning", ` - ${err}`));
720
734
  }
721
- if (parseErrors.length > PARSE_ERRORS_LIMIT) {
722
- lines.push(theme.fg("dim", ` … ${parseErrors.length - PARSE_ERRORS_LIMIT} more`));
735
+ if (fullCount > capped.length) {
736
+ lines.push(theme.fg("dim", ` … ${fullCount - capped.length} more`));
723
737
  }
724
738
  }
725
739
 
@@ -727,11 +741,11 @@ export function appendParseErrorsBulletList(
727
741
  * Human-readable summary string for the parse-issues count, capped by
728
742
  * {@link PARSE_ERRORS_LIMIT}.
729
743
  */
730
- export function formatParseErrorsCountLabel(parseErrors: readonly string[]): string {
731
- const total = parseErrors.length;
732
- return total > PARSE_ERRORS_LIMIT
733
- ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
734
- : `${total} parse issue${total !== 1 ? "s" : ""}`;
744
+ export function formatParseErrorsCountLabel(parseErrors: readonly string[], total?: number): string {
745
+ const fullCount = total ?? parseErrors.length;
746
+ return fullCount > PARSE_ERRORS_LIMIT
747
+ ? `${PARSE_ERRORS_LIMIT} / ${fullCount} parse issues`
748
+ : `${fullCount} parse issue${fullCount !== 1 ? "s" : ""}`;
735
749
  }
736
750
 
737
751
  // =============================================================================