@oh-my-pi/pi-coding-agent 15.1.5 → 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,12 @@
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
+
5
11
  ## [15.1.5] - 2026-05-19
6
12
 
7
13
  ### Fixed
@@ -156,6 +162,7 @@
156
162
 
157
163
  ### Fixed
158
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))
159
166
  - Fixed bash command fixups to remove a redundant standalone trailing `2>&1` redirect when no other pipe or redirection remains
160
167
  - Fixed command-fixup notices to list all stripped segments instead of reporting only one
161
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. */
@@ -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
  /**
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.5",
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.5",
51
- "@oh-my-pi/pi-agent-core": "15.1.5",
52
- "@oh-my-pi/pi-ai": "15.1.5",
53
- "@oh-my-pi/pi-natives": "15.1.5",
54
- "@oh-my-pi/pi-tui": "15.1.5",
55
- "@oh-my-pi/pi-utils": "15.1.5",
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. */
@@ -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
  }