@oh-my-pi/pi-coding-agent 14.2.1 → 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 (44) 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/debug/system-info.ts +6 -2
  7. package/src/discovery/claude.ts +58 -36
  8. package/src/discovery/opencode.ts +20 -2
  9. package/src/edit/index.ts +2 -1
  10. package/src/edit/modes/chunk.ts +132 -56
  11. package/src/edit/modes/hashline.ts +36 -11
  12. package/src/edit/renderer.ts +98 -133
  13. package/src/edit/streaming.ts +351 -0
  14. package/src/exec/bash-executor.ts +60 -5
  15. package/src/internal-urls/docs-index.generated.ts +5 -5
  16. package/src/internal-urls/pi-protocol.ts +0 -2
  17. package/src/lsp/client.ts +8 -1
  18. package/src/lsp/defaults.json +2 -1
  19. package/src/modes/acp/acp-agent.ts +76 -2
  20. package/src/modes/components/assistant-message.ts +1 -34
  21. package/src/modes/components/hook-editor.ts +1 -1
  22. package/src/modes/components/tool-execution.ts +111 -101
  23. package/src/modes/controllers/input-controller.ts +1 -1
  24. package/src/modes/interactive-mode.ts +0 -2
  25. package/src/modes/theme/mermaid-cache.ts +13 -52
  26. package/src/modes/theme/theme.ts +2 -2
  27. package/src/prompts/system/system-prompt.md +1 -1
  28. package/src/prompts/tools/browser.md +1 -0
  29. package/src/prompts/tools/chunk-edit.md +25 -22
  30. package/src/prompts/tools/gh-pr-push.md +2 -1
  31. package/src/prompts/tools/grep.md +4 -3
  32. package/src/prompts/tools/lsp.md +6 -0
  33. package/src/prompts/tools/read-chunk.md +46 -7
  34. package/src/prompts/tools/read.md +7 -4
  35. package/src/sdk.ts +8 -5
  36. package/src/session/agent-session.ts +36 -20
  37. package/src/session/session-manager.ts +228 -57
  38. package/src/session/streaming-output.ts +11 -0
  39. package/src/system-prompt.ts +7 -2
  40. package/src/task/executor.ts +1 -0
  41. package/src/tools/bash.ts +13 -0
  42. package/src/tools/gh.ts +6 -16
  43. package/src/tools/sqlite-reader.ts +116 -3
  44. 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,11 +609,8 @@ export function isChunkParams(params: unknown): params is ChunkParams {
549
609
  return false;
550
610
  }
551
611
  const first = params.edits[0];
552
- // Accept a bare `{ path }` entry: it is interpreted downstream as a chunk
553
- // delete. Some providers strip `null` values from tool-call JSON, so a
554
- // documented `{ path, write: null }` delete can arrive here as just
555
- // `{ path }`. Rejecting that surfaced as a misleading
556
- // "Invalid edit parameters for chunk mode." error.
612
+ // Accept a bare `{ path }` entry so the executor can return a targeted
613
+ // "missing operation" error instead of the generic schema failure.
557
614
  return typeof first === "object" && first !== null && "path" in first;
558
615
  }
559
616
 
@@ -605,6 +662,29 @@ function autoCorrectBodyIndent(content: string, index: number): { content: strin
605
662
  return { content, warnings };
606
663
  }
607
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
+
608
688
  function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
609
689
  operations: ChunkEditOperation[];
610
690
  warnings: string[];
@@ -612,26 +692,22 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
612
692
  const warnings: string[] = [];
613
693
  const operations = edits.map((edit, index): ChunkEditOperation => {
614
694
  const { selector } = parseChunkEditPath(edit.path);
615
- // When multiple ops are present (model confusion), prefer write (total replacement) as the
616
- // safest default, then replace (surgical), then insert (additive), then delete.
617
- const hasInsert = edit.insert != null && typeof edit.insert.body === "string" && edit.insert.body.length > 0;
618
- const hasReplace =
619
- edit.replace != null &&
620
- ((typeof edit.replace.old === "string" && edit.replace.old.length > 0) ||
621
- (typeof edit.replace.new === "string" && edit.replace.new.length > 0));
622
- const hasWrite = typeof edit.write === "string" && edit.write.length > 0;
623
- const opCount = [hasInsert, hasReplace, hasWrite].filter(Boolean).length;
624
- if (opCount > 1) {
625
- const chosen = hasWrite ? "write" : hasReplace ? "replace" : "insert";
626
- const present = [hasWrite && "write", hasReplace && "replace", hasInsert && "insert"]
627
- .filter(Boolean)
628
- .join(", ");
629
- warnings.push(
630
- `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.`,
631
- );
632
- }
633
- if (hasWrite) {
634
- 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;
635
711
  if (selector?.endsWith("~")) {
636
712
  const corrected = autoCorrectBodyIndent(writeContent, index);
637
713
  writeContent = corrected.content;
@@ -639,15 +715,12 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
639
715
  }
640
716
  return { op: "put", sel: selector, content: writeContent };
641
717
  }
642
- if (typeof edit.write === "string" && !hasInsert && !hasReplace) {
643
- return { op: "put", sel: selector, content: edit.write };
644
- }
645
- if (hasReplace) {
646
- return { op: "replace", sel: selector, content: edit.replace!.new, find: edit.replace!.old };
647
- }
648
- if (hasInsert) {
649
- const op = edit.insert!.loc === "prepend" ? "before" : "after";
650
- 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;
651
724
  if (selector?.endsWith("~")) {
652
725
  const corrected = autoCorrectBodyIndent(insertContent, index);
653
726
  insertContent = corrected.content;
@@ -655,7 +728,9 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
655
728
  }
656
729
  return { op, sel: selector, content: insertContent };
657
730
  }
658
- // write: null or no op specified → delete
731
+ if (operation !== "delete") {
732
+ throw new Error(`Edit ${index + 1}: unsupported chunk edit operation "${operation}".`);
733
+ }
659
734
  return { op: "delete", sel: selector };
660
735
  });
661
736
  return { operations, warnings };
@@ -717,6 +792,7 @@ export async function executeChunkSingle(
717
792
  const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
718
793
  session,
719
794
  path,
795
+ { intent: "write" },
720
796
  );
721
797
  const parentDir = nodePath.dirname(resolvedPath);
722
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