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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/package.json +19 -19
  3. package/src/cli/args.ts +10 -1
  4. package/src/cli/shell-cli.ts +15 -3
  5. package/src/config/settings-schema.ts +60 -1
  6. package/src/dap/session.ts +8 -2
  7. package/src/debug/system-info.ts +6 -2
  8. package/src/discovery/claude.ts +58 -36
  9. package/src/discovery/opencode.ts +20 -2
  10. package/src/edit/index.ts +3 -1
  11. package/src/edit/modes/chunk.ts +133 -53
  12. package/src/edit/modes/hashline.ts +36 -11
  13. package/src/edit/renderer.ts +98 -133
  14. package/src/edit/streaming.ts +351 -0
  15. package/src/exec/bash-executor.ts +60 -5
  16. package/src/internal-urls/docs-index.generated.ts +5 -5
  17. package/src/internal-urls/pi-protocol.ts +0 -2
  18. package/src/lsp/client.ts +22 -6
  19. package/src/lsp/defaults.json +2 -1
  20. package/src/lsp/index.ts +53 -10
  21. package/src/lsp/types.ts +2 -0
  22. package/src/modes/acp/acp-agent.ts +76 -2
  23. package/src/modes/components/assistant-message.ts +1 -34
  24. package/src/modes/components/hook-editor.ts +1 -1
  25. package/src/modes/components/tool-execution.ts +111 -101
  26. package/src/modes/controllers/input-controller.ts +1 -1
  27. package/src/modes/interactive-mode.ts +0 -2
  28. package/src/modes/theme/mermaid-cache.ts +13 -52
  29. package/src/modes/theme/theme.ts +2 -2
  30. package/src/prompts/system/system-prompt.md +1 -1
  31. package/src/prompts/tools/ast-grep.md +1 -0
  32. package/src/prompts/tools/browser.md +1 -0
  33. package/src/prompts/tools/chunk-edit.md +25 -22
  34. package/src/prompts/tools/gh-pr-push.md +2 -1
  35. package/src/prompts/tools/grep.md +4 -3
  36. package/src/prompts/tools/lsp.md +6 -0
  37. package/src/prompts/tools/read-chunk.md +46 -7
  38. package/src/prompts/tools/read.md +7 -4
  39. package/src/sdk.ts +8 -5
  40. package/src/session/agent-session.ts +36 -20
  41. package/src/session/session-manager.ts +228 -57
  42. package/src/session/streaming-output.ts +11 -0
  43. package/src/system-prompt.ts +7 -2
  44. package/src/task/executor.ts +1 -0
  45. package/src/tools/ast-edit.ts +37 -2
  46. package/src/tools/bash.ts +75 -12
  47. package/src/tools/find.ts +19 -26
  48. package/src/tools/gh.ts +6 -16
  49. package/src/tools/grep.ts +94 -37
  50. package/src/tools/path-utils.ts +31 -3
  51. package/src/tools/resolve.ts +12 -3
  52. package/src/tools/sqlite-reader.ts +116 -3
  53. package/src/tools/vim.ts +1 -1
  54. package/src/web/search/providers/codex.ts +129 -6
@@ -32,7 +32,6 @@ export type { ChunkReadTarget };
32
32
 
33
33
  export type ChunkEditOperation =
34
34
  | { op: "put"; sel?: string; content: string }
35
- | { op: "replace"; sel?: string; content: string; find: string }
36
35
  | { op: "delete"; sel?: string }
37
36
  | { op: "before"; sel?: string; content: string }
38
37
  | { op: "after"; sel?: string; content: string }
@@ -120,6 +119,8 @@ type ChunkSourceContext = {
120
119
  chunkLanguage: string | undefined;
121
120
  };
122
121
 
122
+ type ChunkSourceIntent = "read" | "write";
123
+
123
124
  function normalizeLanguage(language: string | undefined): string {
124
125
  return language?.trim().toLowerCase() || "";
125
126
  }
@@ -140,11 +141,17 @@ function fileLanguageTag(filePath: string, language?: string): string | undefine
140
141
  return ext.length > 0 ? ext : undefined;
141
142
  }
142
143
 
143
- async function resolveChunkSourceContext(session: ToolSession, path: string): Promise<ChunkSourceContext> {
144
+ async function resolveChunkSourceContext(
145
+ session: ToolSession,
146
+ path: string,
147
+ options?: { intent?: ChunkSourceIntent },
148
+ ): Promise<ChunkSourceContext> {
144
149
  const resolvedPath = resolvePlanPath(session, path);
145
150
  const sourceFile = Bun.file(resolvedPath);
146
151
  const sourceExists = await sourceFile.exists();
147
- enforcePlanModeWrite(session, path, { op: sourceExists ? "update" : "create" });
152
+ if ((options?.intent ?? "write") === "write") {
153
+ enforcePlanModeWrite(session, path, { op: sourceExists ? "update" : "create" });
154
+ }
148
155
 
149
156
  let rawContent = "";
150
157
  if (sourceExists) {
@@ -161,6 +168,57 @@ async function resolveChunkSourceContext(session: ToolSession, path: string): Pr
161
168
  };
162
169
  }
163
170
 
171
+ /**
172
+ * Preview-safe loader: read raw source without plan-mode enforcement or
173
+ * editable-file guards. Used by streaming diff previews that must not throw
174
+ * side-effecting errors while args are still being streamed.
175
+ */
176
+ export async function loadChunkSource(params: {
177
+ cwd: string;
178
+ path: string;
179
+ }): Promise<{ resolvedPath: string; rawContent: string; language: string | undefined; exists: boolean }> {
180
+ const resolvedPath = nodePath.isAbsolute(params.path) ? params.path : nodePath.resolve(params.cwd, params.path);
181
+ const sourceFile = Bun.file(resolvedPath);
182
+ const exists = await sourceFile.exists();
183
+ const rawContent = exists ? await sourceFile.text() : "";
184
+ return { resolvedPath, rawContent, language: getLanguageFromPath(resolvedPath), exists };
185
+ }
186
+
187
+ /**
188
+ * Compute a unified diff preview for a chunk edit without applying it.
189
+ * Used for streaming previews while args are still arriving. Returns
190
+ * `{ error }` on any failure so callers can decide whether to surface it.
191
+ */
192
+ export async function computeChunkDiff(
193
+ input: { path: string; edits: ChunkToolEdit[] },
194
+ cwd: string,
195
+ options?: { anchorStyle?: ChunkAnchorStyle; signal?: AbortSignal },
196
+ ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
197
+ try {
198
+ options?.signal?.throwIfAborted?.();
199
+ const { filePath } = parseChunkEditPath(input.path);
200
+ if (!filePath) return { error: "chunk edit path is empty" };
201
+ const { resolvedPath, rawContent, language } = await loadChunkSource({ cwd, path: filePath });
202
+ options?.signal?.throwIfAborted?.();
203
+ const { operations } = normalizeChunkEditOperations(input.edits);
204
+ const result = applyChunkEdits({
205
+ source: rawContent,
206
+ language,
207
+ cwd,
208
+ filePath: resolvedPath,
209
+ operations,
210
+ anchorStyle: options?.anchorStyle,
211
+ });
212
+ options?.signal?.throwIfAborted?.();
213
+ if (!result.changed) {
214
+ return { diff: "", firstChangedLine: undefined };
215
+ }
216
+ return generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter);
217
+ } catch (err) {
218
+ return { error: err instanceof Error ? err.message : String(err) };
219
+ }
220
+ }
221
+
164
222
  function normalizeChunkRegionSyntax(text: string): string {
165
223
  return text.replaceAll("@body", "~").replaceAll("@head", "^");
166
224
  }
@@ -189,6 +247,20 @@ function chunkReadPathSeparatorIndex(readPath: string): number {
189
247
  if (/^[a-zA-Z]:[/\\]/.test(readPath)) {
190
248
  return readPath.indexOf(":", 2);
191
249
  }
250
+ const urlMatch = readPath.match(/^([a-z][a-z0-9+.-]*):\/\//i);
251
+ if (urlMatch) {
252
+ const scheme = urlMatch[1].toLowerCase();
253
+ const urlPrefixEnd = urlMatch[0].length;
254
+ if (scheme === "local") {
255
+ const index = readPath.lastIndexOf(":");
256
+ return index >= urlPrefixEnd ? index : -1;
257
+ }
258
+
259
+ const pathStart = readPath.indexOf("/", urlPrefixEnd);
260
+ if (pathStart === -1) return -1;
261
+ const index = readPath.lastIndexOf(":");
262
+ return index >= pathStart ? index : -1;
263
+ }
192
264
  return readPath.indexOf(":");
193
265
  }
194
266
 
@@ -376,15 +448,6 @@ function toNativeEditOperation(
376
448
  region: nativeRegion,
377
449
  content: operation.content,
378
450
  };
379
- case "replace":
380
- return {
381
- op: ChunkEditOp.Replace,
382
- sel: selector,
383
- crc,
384
- region: nativeRegion,
385
- find: operation.find,
386
- content: operation.content,
387
- };
388
451
  case "before":
389
452
  return { op: ChunkEditOp.Before, sel: selector, crc, region: nativeRegion, content: operation.content };
390
453
  case "after":
@@ -491,17 +554,14 @@ export const chunkToolEditSchema = Type.Object(
491
554
  }),
492
555
  write: Type.Optional(
493
556
  Type.Union([Type.String(), Type.Null()], {
494
- description: "Write complete new content to the targeted region. Use null to delete the chunk.",
557
+ description:
558
+ "Write complete new content to the targeted region. Null is rejected; use delete: true for deletion.",
495
559
  }),
496
560
  ),
497
- replace: Type.Optional(
498
- Type.Object(
499
- {
500
- old: Type.String({ description: "Literal substring to find. Must match exactly once." }),
501
- new: Type.String({ description: "Replacement text." }),
502
- },
503
- { description: "Find and replace a substring within the chunk." },
504
- ),
561
+ delete: Type.Optional(
562
+ Type.Boolean({
563
+ description: "Explicitly delete the targeted chunk. Must be true; include the current chunk ID.",
564
+ }),
505
565
  ),
506
566
  insert: Type.Optional(
507
567
  Type.Object(
@@ -549,8 +609,9 @@ export function isChunkParams(params: unknown): params is ChunkParams {
549
609
  return false;
550
610
  }
551
611
  const first = params.edits[0];
552
- if (typeof first !== "object" || first === null || !("path" in first)) return false;
553
- return "write" in first || "replace" in first || "insert" in first;
612
+ // Accept a bare `{ path }` entry so the executor can return a targeted
613
+ // "missing operation" error instead of the generic schema failure.
614
+ return typeof first === "object" && first !== null && "path" in first;
554
615
  }
555
616
 
556
617
  /** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on.
@@ -601,6 +662,29 @@ function autoCorrectBodyIndent(content: string, index: number): { content: strin
601
662
  return { content, warnings };
602
663
  }
603
664
 
665
+ function chunkEditOperationFields(edit: ChunkToolEdit): string[] {
666
+ const fields: string[] = [];
667
+ if (edit.write !== undefined) fields.push("write");
668
+ if (edit.insert != null) fields.push("insert");
669
+ if (edit.delete === true) fields.push("delete");
670
+ return fields;
671
+ }
672
+
673
+ function assertSingleChunkOperation(edit: ChunkToolEdit, index: number): string {
674
+ const fields = chunkEditOperationFields(edit);
675
+ if (fields.length === 0) {
676
+ throw new Error(
677
+ `Edit ${index + 1}: no operation specified. Use write:"..." to replace, insert:{loc,body} to insert, or delete:true to delete. Use the open tool to inspect chunks.`,
678
+ );
679
+ }
680
+ if (fields.length > 1) {
681
+ throw new Error(
682
+ `Edit ${index + 1}: multiple operation fields set (${fields.join(", ")}). Each chunk edit entry must have exactly one operation.`,
683
+ );
684
+ }
685
+ return fields[0];
686
+ }
687
+
604
688
  function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
605
689
  operations: ChunkEditOperation[];
606
690
  warnings: string[];
@@ -608,26 +692,22 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
608
692
  const warnings: string[] = [];
609
693
  const operations = edits.map((edit, index): ChunkEditOperation => {
610
694
  const { selector } = parseChunkEditPath(edit.path);
611
- // When multiple ops are present (model confusion), prefer write (total replacement) as the
612
- // safest default, then replace (surgical), then insert (additive), then delete.
613
- const hasInsert = edit.insert != null && typeof edit.insert.body === "string" && edit.insert.body.length > 0;
614
- const hasReplace =
615
- edit.replace != null &&
616
- ((typeof edit.replace.old === "string" && edit.replace.old.length > 0) ||
617
- (typeof edit.replace.new === "string" && edit.replace.new.length > 0));
618
- const hasWrite = typeof edit.write === "string" && edit.write.length > 0;
619
- const opCount = [hasInsert, hasReplace, hasWrite].filter(Boolean).length;
620
- if (opCount > 1) {
621
- const chosen = hasWrite ? "write" : hasReplace ? "replace" : "insert";
622
- const present = [hasWrite && "write", hasReplace && "replace", hasInsert && "insert"]
623
- .filter(Boolean)
624
- .join(", ");
625
- warnings.push(
626
- `Edit ${index + 1}: multiple operation fields set (${present}). Each edit entry must have exactly ONE of write/replace/insert — not multiple. Used "${chosen}", ignored the rest.`,
627
- );
628
- }
629
- if (hasWrite) {
630
- let writeContent = edit.write!;
695
+ const operation = assertSingleChunkOperation(edit, index);
696
+ if (operation === "write") {
697
+ if (edit.write === null) {
698
+ throw new Error(
699
+ `Edit ${index + 1}: write:null no longer deletes chunks. Use delete:true to delete, or open the chunk to inspect its content without modifying the file.`,
700
+ );
701
+ }
702
+ if (typeof edit.write !== "string") {
703
+ throw new Error(`Edit ${index + 1}: write must be a string.`);
704
+ }
705
+ if (edit.write.length === 0) {
706
+ throw new Error(
707
+ `Edit ${index + 1}: write:"" is a destructive empty replacement. Use delete:true to delete the chunk, or open the chunk to inspect its content without modifying the file.`,
708
+ );
709
+ }
710
+ let writeContent = edit.write;
631
711
  if (selector?.endsWith("~")) {
632
712
  const corrected = autoCorrectBodyIndent(writeContent, index);
633
713
  writeContent = corrected.content;
@@ -635,15 +715,12 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
635
715
  }
636
716
  return { op: "put", sel: selector, content: writeContent };
637
717
  }
638
- if (typeof edit.write === "string" && !hasInsert && !hasReplace) {
639
- return { op: "put", sel: selector, content: edit.write };
640
- }
641
- if (hasReplace) {
642
- return { op: "replace", sel: selector, content: edit.replace!.new, find: edit.replace!.old };
643
- }
644
- if (hasInsert) {
645
- const op = edit.insert!.loc === "prepend" ? "before" : "after";
646
- let insertContent = edit.insert!.body;
718
+ if (operation === "insert") {
719
+ if (edit.insert == null || typeof edit.insert.body !== "string" || edit.insert.body.length === 0) {
720
+ throw new Error(`Edit ${index + 1}: insert.body must be a non-empty string.`);
721
+ }
722
+ const op = edit.insert.loc === "prepend" ? "before" : "after";
723
+ let insertContent = edit.insert.body;
647
724
  if (selector?.endsWith("~")) {
648
725
  const corrected = autoCorrectBodyIndent(insertContent, index);
649
726
  insertContent = corrected.content;
@@ -651,7 +728,9 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
651
728
  }
652
729
  return { op, sel: selector, content: insertContent };
653
730
  }
654
- // write: null or no op specified → delete
731
+ if (operation !== "delete") {
732
+ throw new Error(`Edit ${index + 1}: unsupported chunk edit operation "${operation}".`);
733
+ }
655
734
  return { op: "delete", sel: selector };
656
735
  });
657
736
  return { operations, warnings };
@@ -713,6 +792,7 @@ export async function executeChunkSingle(
713
792
  const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
714
793
  session,
715
794
  path,
795
+ { intent: "write" },
716
796
  );
717
797
  const parentDir = nodePath.dirname(resolvedPath);
718
798
  if (parentDir && parentDir !== ".") {
@@ -52,12 +52,14 @@ export type HashlineEdit =
52
52
  const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
53
53
  const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
54
54
  const DIFF_PLUS_RE = /^[+](?![+])/;
55
+ const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
55
56
 
56
57
  type LinePrefixStats = {
57
58
  nonEmpty: number;
58
59
  hashPrefixCount: number;
59
60
  diffPlusHashPrefixCount: number;
60
61
  diffPlusCount: number;
62
+ truncationNoticeCount: number;
61
63
  };
62
64
 
63
65
  function collectLinePrefixStats(lines: string[]): LinePrefixStats {
@@ -66,10 +68,15 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
66
68
  hashPrefixCount: 0,
67
69
  diffPlusHashPrefixCount: 0,
68
70
  diffPlusCount: 0,
71
+ truncationNoticeCount: 0,
69
72
  };
70
73
 
71
74
  for (const line of lines) {
72
75
  if (line.length === 0) continue;
76
+ if (READ_TRUNCATION_NOTICE_RE.test(line)) {
77
+ stats.truncationNoticeCount++;
78
+ continue;
79
+ }
73
80
  stats.nonEmpty++;
74
81
  if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++;
75
82
  if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
@@ -79,6 +86,20 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
79
86
  return stats;
80
87
  }
81
88
 
89
+ function stripLeadingHashlinePrefixes(line: string): string {
90
+ let result = line;
91
+ let prev: string;
92
+ do {
93
+ prev = result;
94
+ result = result.replace(HASHLINE_PREFIX_RE, "");
95
+ } while (result !== prev);
96
+ return result;
97
+ }
98
+
99
+ function _filterTruncationNotices(lines: string[]): string[] {
100
+ return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line));
101
+ }
102
+
82
103
  export function stripNewLinePrefixes(lines: string[]): string[] {
83
104
  const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines);
84
105
  if (nonEmpty === 0) return lines;
@@ -88,20 +109,24 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
88
109
  !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
89
110
  if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
90
111
 
91
- return lines.map(line => {
92
- if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "");
93
- if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
94
- if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
95
- return line.replace(HASHLINE_PREFIX_RE, "");
96
- }
97
- return line;
98
- });
112
+ const mapped = lines
113
+ .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
114
+ .map(line => {
115
+ if (stripHash) return stripLeadingHashlinePrefixes(line);
116
+ if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
117
+ if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
118
+ return line.replace(HASHLINE_PREFIX_RE, "");
119
+ }
120
+ return line;
121
+ });
122
+ return mapped;
99
123
  }
100
124
 
101
125
  export function stripHashlinePrefixes(lines: string[]): string[] {
102
126
  const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines);
103
- if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines;
104
- return lines.map(line => line.replace(HASHLINE_PREFIX_RE, ""));
127
+ if (nonEmpty === 0) return lines;
128
+ if (hashPrefixCount !== nonEmpty) return lines;
129
+ return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
105
130
  }
106
131
 
107
132
  const linesSchema = Type.Union([
@@ -530,7 +555,7 @@ export class HashlineMismatchError extends Error {
530
555
  const lines: string[] = [];
531
556
 
532
557
  lines.push(
533
- `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`,
558
+ `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied. Use the updated LINE#ID references shown below (>>> marks changed lines) and retry the edit.`,
534
559
  );
535
560
  lines.push("");
536
561