@oh-my-pi/pi-coding-agent 15.5.0 → 15.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.1] - 2026-05-26
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed the `href`, `hrefr`, and `hline` Handlebars prompt helpers along with the shared hashline anchor state; none were referenced by any built-in or user prompt template
10
+
5
11
  ## [15.5.0] - 2026-05-26
6
12
 
7
13
  ### Added
@@ -28,9 +28,12 @@ export declare class HashlineExecutor {
28
28
  */
29
29
  feed(token: HashlineToken): void;
30
30
  /**
31
- * Flush any open pending op (including its trailing blank lines, which
32
- * are payload-significant) and return the accumulated edits and
33
- * warnings. The executor is single-use; reset() is required for reuse.
31
+ * Flush any open pending op (with its full accumulated payload, blanks
32
+ * included) and return the accumulated edits and warnings. The executor
33
+ * is single-use; reset() is required for reuse.
34
+ * Throws if two replace/delete ops target the same line — that pattern
35
+ * means the diff is painting a before/after picture instead of stating
36
+ * the final state, and applying both would silently duplicate content.
34
37
  */
35
38
  end(): {
36
39
  edits: HashlineEdit[];
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.5.0",
4
+ "version": "15.5.1",
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.5.0",
51
- "@oh-my-pi/pi-agent-core": "15.5.0",
52
- "@oh-my-pi/pi-ai": "15.5.0",
53
- "@oh-my-pi/pi-natives": "15.5.0",
54
- "@oh-my-pi/pi-tui": "15.5.0",
55
- "@oh-my-pi/pi-utils": "15.5.0",
50
+ "@oh-my-pi/omp-stats": "15.5.1",
51
+ "@oh-my-pi/pi-agent-core": "15.5.1",
52
+ "@oh-my-pi/pi-ai": "15.5.1",
53
+ "@oh-my-pi/pi-natives": "15.5.1",
54
+ "@oh-my-pi/pi-tui": "15.5.1",
55
+ "@oh-my-pi/pi-utils": "15.5.1",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -8,7 +8,6 @@ import {
8
8
  parseFrontmatter,
9
9
  prompt,
10
10
  } from "@oh-my-pi/pi-utils";
11
- import { HL_LINE_BODY_SEP } from "../hashline/hash";
12
11
  import { jtdToTypeScript } from "../tools/jtd-to-typescript";
13
12
  import { parseCommandArgs, substituteArgs } from "../utils/command-args";
14
13
 
@@ -30,130 +29,6 @@ prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => {
30
29
  }
31
30
  });
32
31
 
33
- function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
34
- const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
35
- const raw = typeof content === "string" ? content : String(content ?? "");
36
- const text = raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
37
- const ref = `${num}`;
38
- return { num, text, ref };
39
- }
40
-
41
- interface HashlineHelperRef {
42
- line: number;
43
- ref: string;
44
- }
45
-
46
- interface HashlineHelperState {
47
- last?: HashlineHelperRef;
48
- byLine: Map<number, HashlineHelperRef>;
49
- }
50
-
51
- const HL_HELPER_STATE = Symbol("hashlineHelperState");
52
-
53
- interface HashlineHelperStateHolder {
54
- [HL_HELPER_STATE]?: HashlineHelperState;
55
- }
56
-
57
- function isHelperOptions(value: unknown): value is prompt.HelperOptions {
58
- return typeof value === "object" && value !== null && "hash" in value;
59
- }
60
-
61
- function splitHelperArgs(args: unknown[]): { positional: unknown[]; options?: prompt.HelperOptions } {
62
- const maybeOptions = args.at(-1);
63
- if (!isHelperOptions(maybeOptions)) return { positional: args };
64
- return { positional: args.slice(0, -1), options: maybeOptions };
65
- }
66
-
67
- function getHashlineHelperState(context: unknown, options: prompt.HelperOptions | undefined): HashlineHelperState {
68
- const data = options?.data;
69
- const root = data?.root;
70
- const holderTarget = data && typeof data === "object" ? data : root && typeof root === "object" ? root : context;
71
- if (!holderTarget || typeof holderTarget !== "object") {
72
- throw new Error("hashline prompt helpers require an object render context");
73
- }
74
-
75
- const holder = holderTarget as HashlineHelperStateHolder;
76
- if (!holder[HL_HELPER_STATE]) {
77
- holder[HL_HELPER_STATE] = { byLine: new Map() };
78
- }
79
- return holder[HL_HELPER_STATE];
80
- }
81
-
82
- function isLineNumberArg(value: unknown): boolean {
83
- const num = typeof value === "number" ? value : Number.parseInt(String(value), 10);
84
- return Number.isFinite(num);
85
- }
86
-
87
- function rememberHashlineRef(state: HashlineHelperState, line: number, ref: string): void {
88
- const entry = { line, ref };
89
- state.last = entry;
90
- state.byLine.set(line, entry);
91
- }
92
-
93
- function requireStoredHashlineRef(state: HashlineHelperState, lineArg?: unknown): string {
94
- if (lineArg === undefined) {
95
- if (!state.last) {
96
- throw new Error("{{href}} requires a previous {{hline}} call in the same prompt render");
97
- }
98
- return state.last.ref;
99
- }
100
-
101
- const line = typeof lineArg === "number" ? lineArg : Number.parseInt(String(lineArg), 10);
102
- const entry = state.byLine.get(line);
103
- if (!entry) {
104
- throw new Error(`{{href ${line}}} requires a previous {{hline ${line} ...}} call in the same prompt render`);
105
- }
106
- return entry.ref;
107
- }
108
-
109
- function wrapHashlineRef(ref: string, args: unknown[]): string {
110
- const preStr = typeof args[0] === "string" ? args[0] : "";
111
- const postStr = typeof args[1] === "string" ? args[1] : "";
112
- return `${preStr}${ref}${postStr}`;
113
- }
114
-
115
- function resolveHashlineRef(state: HashlineHelperState, args: unknown[]): string {
116
- if (args.length === 0) return requireStoredHashlineRef(state);
117
- const [first, second, ...rest] = args;
118
- if (isLineNumberArg(first)) {
119
- if (second === undefined) return requireStoredHashlineRef(state, first);
120
- const { ref } = formatHashlineRef(first, second);
121
- return wrapHashlineRef(ref, rest);
122
- }
123
- return wrapHashlineRef(requireStoredHashlineRef(state), args);
124
- }
125
-
126
- /**
127
- * {{href lineNum "content"}} — compute a hashline line ref for prompt examples.
128
- * {{href lineNum}} — quote the ref remembered by the earlier {{hline lineNum "..."}}
129
- * {{href}} — quote the ref from the previous {{hline}} call.
130
- * {{href "[" "]"}} — wrap the previous {{hline}} ref with pre/post chars.
131
- * Returns `"lineNum"` (e.g., `"42"`), or `"[42]"` when pre/post are supplied.
132
- */
133
- prompt.registerHelper("href", function (this: unknown, ...args: unknown[]): string {
134
- const { positional, options } = splitHelperArgs(args);
135
- const state = getHashlineHelperState(this, options);
136
- return JSON.stringify(resolveHashlineRef(state, positional));
137
- });
138
- prompt.registerHelper("hrefr", function (this: unknown, ...args: unknown[]): string {
139
- const { positional, options } = splitHelperArgs(args);
140
- const state = getHashlineHelperState(this, options);
141
- return resolveHashlineRef(state, positional);
142
- });
143
-
144
- /**
145
- * {{hline lineNum "content"}} — format a full read-style line with prefix.
146
- * Returns `"lineNum:content"` (colon between line number and content).
147
- */
148
- prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): string {
149
- const { positional, options } = splitHelperArgs(args);
150
- const [lineNum, content] = positional;
151
- const { num, ref, text } = formatHashlineRef(lineNum, content);
152
- const state = getHashlineHelperState(this, options);
153
- rememberHashlineRef(state, num, ref);
154
- return `${ref}${HL_LINE_BODY_SEP}${text}`;
155
- });
156
-
157
32
  const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
158
33
  const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/;
159
34
 
@@ -30,7 +30,6 @@ type PendingOp =
30
30
  interface Pending {
31
31
  op: PendingOp;
32
32
  payload: string[];
33
- pendingBlanks: number;
34
33
  }
35
34
 
36
35
  /**
@@ -81,16 +80,16 @@ export class HashlineExecutor {
81
80
  this.#terminated = true;
82
81
  return;
83
82
  case "header":
84
- this.#flushPending(false);
83
+ this.#flushPending();
85
84
  return;
86
85
  case "blank":
87
- if (this.#pending) this.#pending.pendingBlanks++;
86
+ if (this.#pending) this.#pending.payload.push("");
88
87
  return;
89
88
  case "payload":
90
89
  this.#handlePayload(token.text, token.lineNum);
91
90
  return;
92
91
  case "op-delete":
93
- this.#flushPending(false);
92
+ this.#flushPending();
94
93
  if (token.trailingPayload) {
95
94
  throw new Error(
96
95
  `line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
@@ -102,32 +101,34 @@ export class HashlineExecutor {
102
101
  }
103
102
  return;
104
103
  case "op-insert":
105
- this.#flushPending(false);
104
+ this.#flushPending();
106
105
  this.#pending = {
107
106
  op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
108
107
  payload: [token.inlineBody ?? ""],
109
- pendingBlanks: 0,
110
108
  };
111
109
  return;
112
110
  case "op-replace":
113
- this.#flushPending(false);
111
+ this.#flushPending();
114
112
  validateRangeOrder(token.range, token.lineNum);
115
113
  this.#pending = {
116
114
  op: { kind: "replace", range: token.range, lineNum: token.lineNum },
117
115
  payload: [token.inlineBody ?? ""],
118
- pendingBlanks: 0,
119
116
  };
120
117
  return;
121
118
  }
122
119
  }
123
120
 
124
121
  /**
125
- * Flush any open pending op (including its trailing blank lines, which
126
- * are payload-significant) and return the accumulated edits and
127
- * warnings. The executor is single-use; reset() is required for reuse.
122
+ * Flush any open pending op (with its full accumulated payload, blanks
123
+ * included) and return the accumulated edits and warnings. The executor
124
+ * is single-use; reset() is required for reuse.
125
+ * Throws if two replace/delete ops target the same line — that pattern
126
+ * means the diff is painting a before/after picture instead of stating
127
+ * the final state, and applying both would silently duplicate content.
128
128
  */
129
129
  end(): { edits: HashlineEdit[]; warnings: string[] } {
130
- this.#flushPending(true);
130
+ this.#flushPending();
131
+ this.#validateNoOverlappingDeletes();
131
132
  return { edits: this.#edits, warnings: this.#warnings };
132
133
  }
133
134
 
@@ -140,16 +141,44 @@ export class HashlineExecutor {
140
141
  this.#terminated = false;
141
142
  }
142
143
 
144
+ /**
145
+ * Each `:` / `!` op contributes a delete edit per line in its range; if
146
+ * any line ends up targeted by deletes originating from two different
147
+ * source ops (distinguished by their `lineNum`), the patch is internally
148
+ * inconsistent. Common shape: a "before" `A-B:` followed by an "after"
149
+ * `A-B:` over the same range, or an `A-B:` that overlaps a later `N!` /
150
+ * `N:`. The applier would run both literally and the file would end up
151
+ * with two copies of the line, not a chosen winner.
152
+ */
153
+ #validateNoOverlappingDeletes(): void {
154
+ const sourceLinesByAnchor = new Map<number, number[]>();
155
+ for (const edit of this.#edits) {
156
+ if (edit.kind !== "delete") continue;
157
+ let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
158
+ if (sourceLines === undefined) {
159
+ sourceLines = [];
160
+ sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
161
+ }
162
+ if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
163
+ }
164
+ for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
165
+ if (sourceLines.length < 2) continue;
166
+ const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
167
+ throw new Error(
168
+ `line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` +
169
+ `Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
170
+ );
171
+ }
172
+ }
173
+
143
174
  #handlePayload(text: string, lineNum: number): void {
144
175
  if (this.#pending) {
145
- this.#flushPendingBlanks();
146
176
  this.#pending.payload.push(text);
147
177
  return;
148
178
  }
149
179
 
150
- // Whitespace-only payload outside any pending op is a visual
151
- // separator (matches the legacy outer-loop isBlankLine skip);
152
- // only fully-empty lines arrive as `blank` tokens.
180
+ // Whitespace-only payload outside any pending op is silently dropped;
181
+ // fully empty lines arrive as `blank` tokens.
153
182
  if (text.trim().length === 0) return;
154
183
  // Orphan payload outside any pending op: pick the most specific
155
184
  // diagnostic so the model sees the actionable hint.
@@ -174,16 +203,9 @@ export class HashlineExecutor {
174
203
  );
175
204
  }
176
205
 
177
- #flushPendingBlanks(): void {
178
- if (!this.#pending) return;
179
- for (let count = 0; count < this.#pending.pendingBlanks; count++) this.#pending.payload.push("");
180
- this.#pending.pendingBlanks = 0;
181
- }
182
-
183
- #flushPending(includeTrailingBlanks: boolean): void {
206
+ #flushPending(): void {
184
207
  const pending = this.#pending;
185
208
  if (!pending) return;
186
- if (includeTrailingBlanks) this.#flushPendingBlanks();
187
209
 
188
210
  const { op, payload } = pending;
189
211
  const linesToInsert = payload;
@@ -1,110 +1,59 @@
1
1
  Your patch language is a compact, line-anchored edit format.
2
2
 
3
- A patch contains one or more file sections. Each anchored section starts with `¶PATH#HASH`, copied verbatim from the latest `read`/`search` output. `HASH` is a 4-hex file hash; `¶PATH` without `#HASH` is allowed only for new-file / `BOF` / `EOF` boundary inserts.
4
-
5
- Operations reference lines by bare line number (`5`, `123`). Payload text is verbatim — NEVER escape unicode. The tool has NO awareness of language, indentation, brackets, fences, or table widths. Emit valid syntax in replacements/insertions.
3
+ <payload>
4
+ Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`.
5
+ - No context rows, no gutters.
6
+ - NEVER prefix payload with diff syntax.
7
+ - NEVER restate unchanged lines "for context".
8
+ - Payload indentation is literal.
9
+ </payload>
6
10
 
7
11
  <ops>
8
- ¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH
9
- ¶PATH unbound header: only BOF/EOF boundary inserts
10
- LINE↑PAYLOAD insert ABOVE the anchored line (or BOF)
11
- LINE↓PAYLOAD insert BELOW the anchored line (or EOF)
12
- A-B:PAYLOAD replace the inclusive range A..B with PAYLOAD
13
- A:PAYLOAD shorthand for A-A:PAYLOAD
14
- A-B! delete the inclusive range A..B (payload forbidden)
15
- A! shorthand for A-A!
12
+ LINE↑PAYLOAD insert before (or BOF↑)
13
+ LINE↓PAYLOAD insert after (or EOF↓)
14
+ A-B:PAYLOAD replace A..B (or A: == A..A)
15
+ A-B! delete A..B (or A! == A..A)
16
16
  </ops>
17
17
 
18
- <payload>
19
- - The first payload line is whatever follows the sigil on the op line. Additional payload lines follow on the next lines and append after the first.
20
- - An empty inline IS an empty first line. So bare `A↓` / `A↑` insert one blank line; bare `A:` / `A-B:` replace with one blank line. `A↓\nfoo` inserts blank-then-`foo`, NOT just `foo`.
21
- - Payload ends at the next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are dropped; blank lines between content lines are preserved.
22
- </payload>
23
-
24
18
  <rules>
25
- - The sigil tells where content lands: `↑` above, `↓` below, `:` replaces, `!` deletes.
26
- - **Payload is only what's NEW relative to your range.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat the anchor line or neighbors — that duplicates them.
27
- - **Pick a self-contained unit.** Touching a multiline construct (return, array, brace block, JSX element)? Widen the range to span it. Don't bisect.
28
- - Smallest op wins: add with `↑`/`↓`; replace with `:`; delete with `!`.
29
- - Anchors reference the file as last read. ONE patch, ONE coordinate space — later ops still use original line numbers.
19
+ - **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
20
+ - **Go small.** Add `↑`/`↓`; replace `:`; delete `!`.
21
+ - **Line numbers are frozen references to what you have seen.** Later ops still use original line numbers.
30
22
  </rules>
31
23
 
32
24
  <common-failures>
33
- - **NEVER replay past your range.** Stop before B+1; extend B if it must go.
34
- - **NEVER duplicate chunks inside one payload.**
35
- - **Read lines look like replace ops.** `84:content` already means "make line 84 equal to content" — don't echo a context line before it.
25
+ - **NEVER replay past your range.** Stop before B+1; extend B if needed.
26
+ - **Read lines look like replace ops.** `84:content` = "make line 84 content" — don't echo context before it.
36
27
  - **NEVER fabricate file hashes.** Missing? Re-`read`.
37
- - **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error.
38
- - **Pure removal uses `A-B!`, NEVER `A-B:something`.** If you have nothing to put in the range, use `!`. `A-B:X` where line `A-1` or `B+1` already reads `X` silently produces two copies of `X` — the tool trusts your payload literally. Before writing `A-B:payload`, glance at `A-1` and `B+1` and confirm payload doesn't echo either.
39
28
  </common-failures>
40
29
 
41
- <case file="mod.ts">
42
- ¶mod.ts#1a2b
43
- {{hline 1 'const TITLE = "Mr";'}}
44
- {{hline 2 'export function greet(name) {'}}
45
- {{hline 3 ' return ['}}
46
- {{hline 4 ' TITLE,'}}
47
- {{hline 5 ' name?.trim() || "guest",'}}
48
- {{hline 6 ' ].join(" ");'}}
49
- {{hline 7 "}"}}
50
- </case>
51
-
52
- <examples>
53
- # Replace one line (inline payload preserves original indentation)
54
- ¶mod.ts#1a2b
55
- {{hrefr 1}}:const TITLE = "Mrs";
56
-
57
- # Replace a multiline statement — first line inline, rest below
58
- ¶mod.ts#1a2b
59
- {{hrefr 3}}-{{hrefr 6}}: return [
60
- "Mrs",
61
- name?.trim() || "guest",
62
- ].join(" ");
63
-
64
- # Insert ABOVE / BELOW a line
65
- ¶mod.ts#1a2b
66
- {{hrefr 4}}↓ "Dr",
67
- {{hrefr 5}}↑ "Dr",
68
-
69
- # Delete one line / blank a line / insert a blank line
70
- ¶mod.ts#1a2b
71
- {{hrefr 5}}!
72
- {{hrefr 6}}:
73
- {{hrefr 7}}↑
74
-
75
- # Create a file / append to one (hash optional for boundary-only inserts)
76
- ¶new.ts
77
- BOF↓export const done = true;
78
- ¶mod.ts
79
- EOF↓export const done = true;
80
-
81
- # Multi-file patch
82
- ¶src/a.ts#1a2b
83
- 12:const enabled = true;
84
- ¶src/b.ts#3c4d
85
- 20!
86
- </examples>
30
+ <example>
31
+ ```a.ts#1a2b
32
+ 1:const X = "a";
33
+ 2:export function f() { return X; }
34
+ ```
35
+
36
+ # replace, insert after, delete
37
+ ```
38
+ ¶a.ts#1a2b
39
+ 1:const X = "b";
40
+ 1↓const Y = "c";
41
+ 2!
42
+ ```
43
+ </example>
87
44
 
88
45
  <anti-pattern>
89
- # WRONG — replaces 2 lines just to add one.
90
- ¶mod.ts#1a2b
91
- {{hrefr 1}}-{{hrefr 2}}:const TITLE = "Mr";
92
- const DEBUG = false;
93
- export function greet(name) {
94
-
95
- # RIGHT — one-line insert
96
- ¶mod.ts#1a2b
97
- {{hrefr 1}}↓const DEBUG = false;
98
-
99
- # WRONG — bisects a multiline statement
100
- ¶mod.ts#1a2b
101
- {{hrefr 4}}-{{hrefr 5}}: "Dr",
102
- name?.trim() || "guest",
103
-
104
- # RIGHT — widen to the full statement
105
- ¶mod.ts#1a2b
106
- {{hrefr 3}}-{{hrefr 6}}: return [
107
- "Dr",
108
- name?.trim() || "guest",
109
- ].join(" ");
46
+ # WRONG — INSERT used to change a line (old line survives)
47
+ 1↓const X = "b";
48
+ # WRONG — echoing read-style lines as context before the real op
49
+ 1:const X = "a";
50
+ 1-2:const X = "b";
51
+ export const Y = X;
110
52
  </anti-pattern>
53
+
54
+ <critical>
55
+ - One op per range, ever.
56
+ - Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`.
57
+ - Payload is only what's NEW; never repeat anchor lines or neighbors.
58
+ - Anchor exactly; don't anchor neighbors.
59
+ </critical>