@oh-my-pi/pi-coding-agent 14.1.1 → 14.2.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 (123) hide show
  1. package/CHANGELOG.md +47 -2
  2. package/package.json +8 -8
  3. package/scripts/build-binary.ts +61 -0
  4. package/src/autoresearch/helpers.ts +10 -0
  5. package/src/autoresearch/index.ts +1 -11
  6. package/src/autoresearch/tools/init-experiment.ts +1 -10
  7. package/src/autoresearch/tools/log-experiment.ts +1 -11
  8. package/src/autoresearch/tools/run-experiment.ts +1 -10
  9. package/src/bun-imports.d.ts +6 -0
  10. package/src/cli/plugin-cli.ts +23 -45
  11. package/src/commit/agentic/tools/propose-commit.ts +1 -14
  12. package/src/commit/agentic/tools/split-commit.ts +1 -15
  13. package/src/commit/utils.ts +15 -1
  14. package/src/config/model-registry.ts +3 -3
  15. package/src/config/prompt-templates.ts +4 -12
  16. package/src/config/settings-schema.ts +27 -2
  17. package/src/config/settings.ts +1 -1
  18. package/src/discovery/claude-plugins.ts +61 -6
  19. package/src/discovery/codex.ts +2 -15
  20. package/src/discovery/gemini.ts +2 -15
  21. package/src/discovery/helpers.ts +40 -1
  22. package/src/discovery/opencode.ts +2 -15
  23. package/src/edit/apply-patch/index.ts +87 -0
  24. package/src/edit/apply-patch/parser.ts +174 -0
  25. package/src/edit/diff.ts +3 -14
  26. package/src/edit/index.ts +65 -2
  27. package/src/edit/modes/apply-patch.lark +19 -0
  28. package/src/edit/modes/apply-patch.ts +63 -0
  29. package/src/edit/modes/hashline.ts +3 -3
  30. package/src/edit/modes/replace.ts +2 -13
  31. package/src/edit/read-file.ts +18 -0
  32. package/src/edit/renderer.ts +61 -33
  33. package/src/extensibility/extensions/compact-handler.ts +40 -0
  34. package/src/extensibility/extensions/runner.ts +11 -29
  35. package/src/extensibility/utils.ts +7 -1
  36. package/src/internal-urls/docs-index.generated.ts +9 -2
  37. package/src/lsp/render.ts +14 -2
  38. package/src/main.ts +1 -0
  39. package/src/mcp/manager.ts +29 -48
  40. package/src/memories/index.ts +7 -1
  41. package/src/modes/acp/acp-agent.ts +3 -16
  42. package/src/modes/components/model-selector.ts +15 -24
  43. package/src/modes/components/plugin-settings.ts +16 -5
  44. package/src/modes/components/read-tool-group.ts +92 -9
  45. package/src/modes/components/settings-defs.ts +18 -0
  46. package/src/modes/components/settings-selector.ts +2 -6
  47. package/src/modes/components/tool-execution.ts +61 -28
  48. package/src/modes/controllers/event-controller.ts +3 -1
  49. package/src/modes/controllers/extension-ui-controller.ts +99 -150
  50. package/src/modes/controllers/selector-controller.ts +3 -12
  51. package/src/modes/interactive-mode.ts +4 -2
  52. package/src/modes/print-mode.ts +4 -22
  53. package/src/modes/rpc/rpc-mode.ts +18 -38
  54. package/src/modes/shared.ts +10 -1
  55. package/src/modes/utils/ui-helpers.ts +6 -2
  56. package/src/plan-mode/approved-plan.ts +5 -4
  57. package/src/prompts/system/subagent-system-prompt.md +4 -4
  58. package/src/prompts/system/subagent-user-prompt.md +2 -2
  59. package/src/prompts/system/system-prompt.md +208 -243
  60. package/src/prompts/tools/apply-patch.md +67 -0
  61. package/src/prompts/tools/ast-edit.md +18 -23
  62. package/src/prompts/tools/ast-grep.md +24 -32
  63. package/src/prompts/tools/bash.md +11 -23
  64. package/src/prompts/tools/debug.md +8 -22
  65. package/src/prompts/tools/find.md +0 -4
  66. package/src/prompts/tools/grep.md +3 -5
  67. package/src/prompts/tools/hashline.md +16 -10
  68. package/src/prompts/tools/python.md +10 -14
  69. package/src/prompts/tools/read.md +17 -24
  70. package/src/prompts/tools/task.md +57 -21
  71. package/src/prompts/tools/todo-write.md +45 -67
  72. package/src/session/agent-session.ts +4 -4
  73. package/src/session/session-manager.ts +15 -7
  74. package/src/session/streaming-output.ts +24 -0
  75. package/src/slash-commands/builtin-registry.ts +3 -14
  76. package/src/task/executor.ts +13 -34
  77. package/src/task/index.ts +82 -18
  78. package/src/task/simple-mode.ts +27 -0
  79. package/src/task/template.ts +17 -3
  80. package/src/task/types.ts +77 -30
  81. package/src/tools/ask.ts +2 -4
  82. package/src/tools/ast-edit.ts +4 -15
  83. package/src/tools/ast-grep.ts +8 -27
  84. package/src/tools/bash-skill-urls.ts +9 -7
  85. package/src/tools/bash.ts +4 -12
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/fetch.ts +1 -14
  88. package/src/tools/file-recorder.ts +35 -0
  89. package/src/tools/find.ts +6 -3
  90. package/src/tools/gh-format.ts +12 -0
  91. package/src/tools/gh-renderer.ts +1 -8
  92. package/src/tools/gh.ts +6 -13
  93. package/src/tools/grep.ts +9 -22
  94. package/src/tools/jtd-to-json-schema.ts +16 -0
  95. package/src/tools/match-line-format.ts +20 -0
  96. package/src/tools/path-utils.ts +30 -2
  97. package/src/tools/plan-mode-guard.ts +6 -5
  98. package/src/tools/python.ts +1 -1
  99. package/src/tools/read.ts +1 -1
  100. package/src/tools/render-utils.ts +38 -6
  101. package/src/tools/renderers.ts +1 -0
  102. package/src/tools/ssh.ts +3 -11
  103. package/src/tools/submit-result.ts +1 -13
  104. package/src/tools/todo-write.ts +137 -103
  105. package/src/tools/write.ts +2 -23
  106. package/src/tui/code-cell.ts +12 -7
  107. package/src/utils/edit-mode.ts +3 -2
  108. package/src/utils/git.ts +1 -1
  109. package/src/vim/engine.ts +41 -58
  110. package/src/web/scrapers/crates-io.ts +1 -14
  111. package/src/web/scrapers/types.ts +13 -0
  112. package/src/web/search/providers/base.ts +13 -0
  113. package/src/web/search/providers/brave.ts +2 -5
  114. package/src/web/search/providers/codex.ts +20 -24
  115. package/src/web/search/providers/gemini.ts +39 -1
  116. package/src/web/search/providers/jina.ts +2 -5
  117. package/src/web/search/providers/kagi.ts +3 -8
  118. package/src/web/search/providers/kimi.ts +3 -7
  119. package/src/web/search/providers/parallel.ts +3 -8
  120. package/src/web/search/providers/synthetic.ts +3 -7
  121. package/src/web/search/providers/tavily.ts +15 -11
  122. package/src/web/search/providers/utils.ts +36 -0
  123. package/src/web/search/providers/zai.ts +3 -7
@@ -28,6 +28,58 @@ const PROVIDER_ID = "claude-plugins";
28
28
  const DISPLAY_NAME = "Claude Code Marketplace";
29
29
  const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
30
30
 
31
+ interface ClaudePluginManifest {
32
+ skills?: string;
33
+ "slash-commands"?: string;
34
+ }
35
+
36
+ interface ResolvedPluginDir {
37
+ dir: string;
38
+ warning?: string;
39
+ }
40
+
41
+ async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
42
+ const manifestPath = path.join(root.path, ".claude-plugin", "plugin.json");
43
+ const raw = await readFile(manifestPath);
44
+ if (raw === null) return null;
45
+
46
+ try {
47
+ const parsed = JSON.parse(raw);
48
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
49
+ return parsed as ClaudePluginManifest;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function isWithinPluginRoot(rootPath: string, targetPath: string): boolean {
56
+ const relative = path.relative(rootPath, targetPath);
57
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
58
+ }
59
+
60
+ async function resolvePluginDir(
61
+ root: ClaudePluginRoot,
62
+ manifestKey: keyof ClaudePluginManifest,
63
+ fallback: string,
64
+ ): Promise<ResolvedPluginDir> {
65
+ const manifest = await readPluginManifest(root);
66
+ const fallbackDir = path.join(root.path, fallback);
67
+ const configured = manifest?.[manifestKey];
68
+ if (typeof configured !== "string" || !configured.trim()) {
69
+ return { dir: fallbackDir };
70
+ }
71
+
72
+ const resolved = path.resolve(root.path, configured.trim());
73
+ if (isWithinPluginRoot(root.path, resolved)) {
74
+ return { dir: resolved };
75
+ }
76
+
77
+ return {
78
+ dir: fallbackDir,
79
+ warning: `[claude-plugins] Ignoring ${String(manifestKey)} path outside plugin root for ${root.id}: ${configured}`,
80
+ };
81
+ }
82
+
31
83
  // =============================================================================
32
84
  // Skills
33
85
  // =============================================================================
@@ -41,17 +93,18 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
41
93
 
42
94
  const results = await Promise.all(
43
95
  roots.map(async root => {
44
- const skillsDir = path.join(root.path, "skills");
96
+ const { dir: skillsDir, warning } = await resolvePluginDir(root, "skills", "skills");
45
97
  const result = await scanSkillsFromDir(ctx, {
46
98
  dir: skillsDir,
47
99
  providerId: PROVIDER_ID,
48
100
  level: root.scope,
49
101
  });
50
- return { root, result };
102
+ return { root, result, warning };
51
103
  }),
52
104
  );
53
105
 
54
- for (const { root, result } of results) {
106
+ for (const { root, result, warning } of results) {
107
+ if (warning) warnings.push(warning);
55
108
  for (const skill of result.items) {
56
109
  if (root.plugin) skill.name = `${root.plugin}:${skill.name}`;
57
110
  items.push(skill);
@@ -75,8 +128,8 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
75
128
 
76
129
  const results = await Promise.all(
77
130
  roots.map(async root => {
78
- const commandsDir = path.join(root.path, "commands");
79
- return loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
131
+ const { dir: commandsDir, warning } = await resolvePluginDir(root, "slash-commands", "commands");
132
+ const result = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
80
133
  extensions: ["md"],
81
134
  transform: (name, content, filePath, source) => {
82
135
  const cmdName = name.replace(/\.md$/, "");
@@ -89,10 +142,12 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
89
142
  };
90
143
  },
91
144
  });
145
+ return { result, warning };
92
146
  }),
93
147
  );
94
148
 
95
- for (const result of results) {
149
+ for (const { result, warning } of results) {
150
+ if (warning) warnings.push(warning);
96
151
  items.push(...result.items);
97
152
  if (result.warnings) warnings.push(...result.warnings);
98
153
  }
@@ -30,9 +30,9 @@ import { toolCapability } from "../capability/tool";
30
30
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
31
31
 
32
32
  import {
33
+ buildExtensionModuleItems,
33
34
  createSourceMeta,
34
35
  discoverExtensionModulePaths,
35
- getExtensionNameFromPath,
36
36
  loadFilesFromDir,
37
37
  SOURCE_PATHS,
38
38
  scanSkillsFromDir,
@@ -251,20 +251,7 @@ async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<Extens
251
251
  discoverExtensionModulePaths(ctx, projectExtensionsDir),
252
252
  ]);
253
253
 
254
- const items: ExtensionModule[] = [
255
- ...userPaths.map(extPath => ({
256
- name: getExtensionNameFromPath(extPath),
257
- path: extPath,
258
- level: "user" as const,
259
- _source: createSourceMeta(PROVIDER_ID, extPath, "user"),
260
- })),
261
- ...projectPaths.map(extPath => ({
262
- name: getExtensionNameFromPath(extPath),
263
- path: extPath,
264
- level: "project" as const,
265
- _source: createSourceMeta(PROVIDER_ID, extPath, "project"),
266
- })),
267
- ];
254
+ const items = buildExtensionModuleItems(PROVIDER_ID, userPaths, projectPaths);
268
255
 
269
256
  return { items, warnings };
270
257
  }
@@ -27,11 +27,11 @@ import { type Settings, settingsCapability } from "../capability/settings";
27
27
  import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
28
28
  import type { LoadContext, LoadResult } from "../capability/types";
29
29
  import {
30
+ buildExtensionModuleItems,
30
31
  calculateDepth,
31
32
  createSourceMeta,
32
33
  discoverExtensionModulePaths,
33
34
  expandEnvVarsDeep,
34
- getExtensionNameFromPath,
35
35
  getProjectPath,
36
36
  getUserPath,
37
37
  } from "./helpers";
@@ -238,20 +238,7 @@ async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<Extens
238
238
  projectExtensionsDir ? discoverExtensionModulePaths(ctx, projectExtensionsDir) : Promise.resolve([]),
239
239
  ]);
240
240
 
241
- const items: ExtensionModule[] = [
242
- ...userPaths.map(extPath => ({
243
- name: getExtensionNameFromPath(extPath),
244
- path: extPath,
245
- level: "user" as const,
246
- _source: createSourceMeta(PROVIDER_ID, extPath, "user"),
247
- })),
248
- ...projectPaths.map(extPath => ({
249
- name: getExtensionNameFromPath(extPath),
250
- path: extPath,
251
- level: "project" as const,
252
- _source: createSourceMeta(PROVIDER_ID, extPath, "project"),
253
- })),
254
- ];
241
+ const items = buildExtensionModuleItems(PROVIDER_ID, userPaths, projectPaths);
255
242
 
256
243
  return { items, warnings: [] };
257
244
  }
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { FileType, glob } from "@oh-my-pi/pi-natives";
6
6
  import { CONFIG_DIR_NAME, getConfigDirName, getProjectDir, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
7
- import { readDirEntries, readFile } from "../capability/fs";
7
+ import type { ExtensionModule } from "../capability/extension-module";
8
+ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../capability/fs";
8
9
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
9
10
  import type { Skill, SkillFrontmatter } from "../capability/skill";
10
11
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
@@ -581,6 +582,31 @@ export function getExtensionNameFromPath(extensionPath: string): string {
581
582
  return base;
582
583
  }
583
584
 
585
+ /**
586
+ * Build ExtensionModule items from discovered user/project paths.
587
+ * Shared across providers that expose extension modules via user + project dirs.
588
+ */
589
+ export function buildExtensionModuleItems(
590
+ providerId: string,
591
+ userPaths: string[],
592
+ projectPaths: string[],
593
+ ): ExtensionModule[] {
594
+ return [
595
+ ...userPaths.map(extPath => ({
596
+ name: getExtensionNameFromPath(extPath),
597
+ path: extPath,
598
+ level: "user" as const,
599
+ _source: createSourceMeta(providerId, extPath, "user"),
600
+ })),
601
+ ...projectPaths.map(extPath => ({
602
+ name: getExtensionNameFromPath(extPath),
603
+ path: extPath,
604
+ level: "project" as const,
605
+ _source: createSourceMeta(providerId, extPath, "project"),
606
+ })),
607
+ ];
608
+ }
609
+
584
610
  // =============================================================================
585
611
  // Claude Code Plugin Cache Helpers
586
612
  // =============================================================================
@@ -896,6 +922,19 @@ export function clearClaudePluginRootsCache(): void {
896
922
  }
897
923
  }
898
924
 
925
+ /**
926
+ * Invalidate fs caches for installed-plugin registry files and reset the
927
+ * in-memory plugin roots cache. Used by MarketplaceManager clients after
928
+ * installing/uninstalling/enabling/disabling plugins.
929
+ */
930
+ export function clearPluginRootsAndCaches(extraPaths?: readonly string[]): void {
931
+ const home = os.homedir();
932
+ invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
933
+ invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
934
+ for (const p of extraPaths ?? []) invalidateFsCache(p);
935
+ clearClaudePluginRootsCache();
936
+ }
937
+
899
938
  // ── Preloaded plugin roots (for sync consumers like LSP config) ─────────────
900
939
  // Populated at startup by preloadPluginRoots(). Read synchronously by
901
940
  // getPreloadedPluginRoots(). Safe degradation: empty array if not warmed.
@@ -28,10 +28,10 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c
28
28
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
29
29
 
30
30
  import {
31
+ buildExtensionModuleItems,
31
32
  createSourceMeta,
32
33
  discoverExtensionModulePaths,
33
34
  expandEnvVarsDeep,
34
- getExtensionNameFromPath,
35
35
  getProjectPath,
36
36
  getUserPath,
37
37
  loadFilesFromDir,
@@ -227,20 +227,7 @@ async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<Extens
227
227
  projectPluginsDir ? discoverExtensionModulePaths(ctx, projectPluginsDir) : Promise.resolve([]),
228
228
  ]);
229
229
 
230
- const items: ExtensionModule[] = [
231
- ...userPaths.map(extPath => ({
232
- name: getExtensionNameFromPath(extPath),
233
- path: extPath,
234
- level: "user" as const,
235
- _source: createSourceMeta(PROVIDER_ID, extPath, "user"),
236
- })),
237
- ...projectPaths.map(extPath => ({
238
- name: getExtensionNameFromPath(extPath),
239
- path: extPath,
240
- level: "project" as const,
241
- _source: createSourceMeta(PROVIDER_ID, extPath, "project"),
242
- })),
243
- ];
230
+ const items = buildExtensionModuleItems(PROVIDER_ID, userPaths, projectPaths);
244
231
 
245
232
  return { items, warnings: [] };
246
233
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Multi-file orchestrator for the Codex `apply_patch` envelope.
3
+ *
4
+ * Decoupled from tool-registration: takes raw patch text + options, parses
5
+ * it, and applies each hunk via the existing single-file `applyPatch` in
6
+ * `../modes/patch.ts`. A future OpenAI freeform/grammar tool variant can
7
+ * call this directly with the raw grammar output.
8
+ *
9
+ * Per spec §6.1, hunks are applied in order and NOT atomically — if hunk
10
+ * N fails, hunks `0..N-1` are already on disk. We surface that by
11
+ * returning the per-file results alongside the error when it happens.
12
+ */
13
+
14
+ import { ApplyPatchError } from "../diff";
15
+ import { type ApplyPatchOptions, type ApplyPatchResult, applyPatch, type PatchInput } from "../modes/patch";
16
+ import { parseApplyPatch } from "./parser";
17
+
18
+ export * from "./parser";
19
+
20
+ export interface ApplyCodexPatchResult {
21
+ /** Single-file apply results in the order they were attempted. */
22
+ results: ApplyPatchResult[];
23
+ /** Affected file paths grouped by operation, for the §9.1 summary. */
24
+ affected: {
25
+ added: string[];
26
+ modified: string[];
27
+ deleted: string[];
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Apply a full Codex `*** Begin Patch` envelope.
33
+ *
34
+ * Note: renames are reported under `modified` with the original path (spec
35
+ * §9.1), not as a delete + add.
36
+ */
37
+ export async function applyCodexPatch(patchText: string, options: ApplyPatchOptions): Promise<ApplyCodexPatchResult> {
38
+ const hunks = parseApplyPatch(patchText);
39
+
40
+ if (hunks.length === 0) {
41
+ throw new ApplyPatchError("No files were modified.");
42
+ }
43
+
44
+ const results: ApplyPatchResult[] = [];
45
+ const affected = {
46
+ added: [] as string[],
47
+ modified: [] as string[],
48
+ deleted: [] as string[],
49
+ };
50
+
51
+ for (const hunk of hunks) {
52
+ const result = await applyPatch(hunk, options);
53
+ results.push(result);
54
+ recordAffected(affected, hunk, result);
55
+ }
56
+
57
+ return { results, affected };
58
+ }
59
+
60
+ function recordAffected(
61
+ affected: ApplyCodexPatchResult["affected"],
62
+ hunk: PatchInput,
63
+ _result: ApplyPatchResult,
64
+ ): void {
65
+ switch (hunk.op) {
66
+ case "create":
67
+ affected.added.push(hunk.path);
68
+ break;
69
+ case "delete":
70
+ affected.deleted.push(hunk.path);
71
+ break;
72
+ case "update":
73
+ affected.modified.push(hunk.path);
74
+ break;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Format the A/M/D summary described in spec §9.1.
80
+ */
81
+ export function formatApplyCodexPatchSummary(affected: ApplyCodexPatchResult["affected"]): string {
82
+ const lines = ["Success. Updated the following files:"];
83
+ for (const p of affected.added) lines.push(`A ${p}`);
84
+ for (const p of affected.modified) lines.push(`M ${p}`);
85
+ for (const p of affected.deleted) lines.push(`D ${p}`);
86
+ return lines.join("\n");
87
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Parser for the Codex `apply_patch` envelope format.
3
+ *
4
+ * *** Begin Patch
5
+ * *** Add File: <path>
6
+ * +<line>
7
+ * *** Delete File: <path>
8
+ * *** Update File: <path>
9
+ * *** Move to: <newpath>
10
+ * @@ <optional context>
11
+ * -old
12
+ * +new
13
+ * context
14
+ * *** End of File
15
+ * *** End Patch
16
+ *
17
+ * Input is the full envelope text (optionally heredoc-wrapped). Output is a
18
+ * list of `PatchInput` records, each ready to hand to the existing
19
+ * single-file `applyPatch()` in `../modes/patch.ts`.
20
+ *
21
+ * Per spec §4.3 Lenient mode: a `<<EOF` / `<<'EOF'` / `<<"EOF"` heredoc
22
+ * wrapper around the whole envelope is stripped before parsing.
23
+ */
24
+
25
+ import { ParseError } from "../diff";
26
+ import type { PatchInput } from "../modes/patch";
27
+
28
+ const BEGIN_PATCH_MARKER = "*** Begin Patch";
29
+ const END_PATCH_MARKER = "*** End Patch";
30
+ const ADD_FILE_MARKER = "*** Add File: ";
31
+ const DELETE_FILE_MARKER = "*** Delete File: ";
32
+ const UPDATE_FILE_MARKER = "*** Update File: ";
33
+ const MOVE_TO_MARKER = "*** Move to: ";
34
+
35
+ interface ParseApplyPatchOptions {
36
+ streaming?: boolean;
37
+ }
38
+
39
+ /**
40
+ * Parse a Codex `*** Begin Patch` envelope into a list of single-file
41
+ * patch inputs.
42
+ */
43
+ export function parseApplyPatch(patchText: string): PatchInput[] {
44
+ return parseApplyPatchWithOptions(patchText, {});
45
+ }
46
+
47
+ /**
48
+ * Best-effort parser for in-progress TUI previews. It tolerates missing
49
+ * envelope markers and incomplete trailing hunks; do not use it to apply edits.
50
+ */
51
+ export function parseApplyPatchStreaming(patchText: string): PatchInput[] {
52
+ return parseApplyPatchWithOptions(patchText, { streaming: true });
53
+ }
54
+
55
+ function parseApplyPatchWithOptions(patchText: string, options: ParseApplyPatchOptions): PatchInput[] {
56
+ const streaming = options.streaming === true;
57
+ let lines = patchText.trim().split("\n");
58
+
59
+ // Lenient heredoc strip: <<EOF / <<'EOF' / <<"EOF" ... EOF
60
+ if (lines.length >= 2) {
61
+ const first = lines[0];
62
+ const last = lines[lines.length - 1].trim();
63
+ const validOpeners = new Set(["<<EOF", "<<'EOF'", '<<"EOF"']);
64
+ if (validOpeners.has(first) && last === "EOF") {
65
+ lines = lines.slice(1, lines.length - 1);
66
+ }
67
+ }
68
+
69
+ if (lines.length === 0 || lines[0].trim() !== BEGIN_PATCH_MARKER) {
70
+ if (streaming) return [];
71
+ throw new ParseError("The first line of the patch must be '*** Begin Patch'");
72
+ }
73
+ const hasEndMarker = lines[lines.length - 1].trim() === END_PATCH_MARKER;
74
+ if (!hasEndMarker && !streaming) {
75
+ throw new ParseError("The last line of the patch must be '*** End Patch'");
76
+ }
77
+
78
+ const hunks: PatchInput[] = [];
79
+ let remaining = hasEndMarker ? lines.slice(1, lines.length - 1) : lines.slice(1);
80
+ // Line numbers are 1-based and include the `*** Begin Patch` line (= 1).
81
+ let lineNumber = 2;
82
+
83
+ while (remaining.length > 0) {
84
+ // Blank separator lines between hunks are ignored (spec §3.3).
85
+ if (remaining[0].trim() === "") {
86
+ remaining = remaining.slice(1);
87
+ lineNumber++;
88
+ continue;
89
+ }
90
+
91
+ const firstLine = remaining[0].trim();
92
+
93
+ if (firstLine.startsWith(ADD_FILE_MARKER)) {
94
+ const path = firstLine.slice(ADD_FILE_MARKER.length);
95
+ let contents = "";
96
+ let consumed = 1;
97
+
98
+ for (let i = 1; i < remaining.length; i++) {
99
+ const line = remaining[i];
100
+ if (line.startsWith("+")) {
101
+ contents += `${line.slice(1)}\n`;
102
+ consumed++;
103
+ } else {
104
+ break;
105
+ }
106
+ }
107
+
108
+ hunks.push({ path, op: "create", diff: contents });
109
+ remaining = remaining.slice(consumed);
110
+ lineNumber += consumed;
111
+ continue;
112
+ }
113
+
114
+ if (firstLine.startsWith(DELETE_FILE_MARKER)) {
115
+ const path = firstLine.slice(DELETE_FILE_MARKER.length);
116
+ hunks.push({ path, op: "delete" });
117
+ remaining = remaining.slice(1);
118
+ lineNumber++;
119
+ continue;
120
+ }
121
+
122
+ if (firstLine.startsWith(UPDATE_FILE_MARKER)) {
123
+ const path = firstLine.slice(UPDATE_FILE_MARKER.length);
124
+ remaining = remaining.slice(1);
125
+ lineNumber++;
126
+
127
+ let movePath: string | undefined;
128
+ if (remaining.length > 0 && remaining[0].startsWith(MOVE_TO_MARKER)) {
129
+ movePath = remaining[0].slice(MOVE_TO_MARKER.length);
130
+ remaining = remaining.slice(1);
131
+ lineNumber++;
132
+ }
133
+
134
+ // The body runs until the next file-op marker or end of input.
135
+ // `*** End of File` is a chunk-terminator and stays inside the body —
136
+ // the downstream unified-diff parser handles it.
137
+ const diffLines: string[] = [];
138
+ while (remaining.length > 0) {
139
+ const line = remaining[0];
140
+ if (
141
+ line.startsWith("*** Add File:") ||
142
+ line.startsWith("*** Delete File:") ||
143
+ line.startsWith("*** Update File:")
144
+ ) {
145
+ break;
146
+ }
147
+ diffLines.push(line);
148
+ remaining = remaining.slice(1);
149
+ lineNumber++;
150
+ }
151
+
152
+ if (diffLines.length === 0) {
153
+ if (streaming) {
154
+ hunks.push({ path, op: "update", rename: movePath, diff: "" });
155
+ continue;
156
+ }
157
+ throw new ParseError(`Update file hunk for path '${path}' is empty`, lineNumber);
158
+ }
159
+
160
+ hunks.push({ path, op: "update", rename: movePath, diff: diffLines.join("\n") });
161
+ continue;
162
+ }
163
+
164
+ if (streaming) {
165
+ break;
166
+ }
167
+ throw new ParseError(
168
+ `'${firstLine}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
169
+ lineNumber,
170
+ );
171
+ }
172
+
173
+ return hunks;
174
+ }
package/src/edit/diff.ts CHANGED
@@ -4,11 +4,11 @@
4
4
  * Provides diff string generation and the replace-mode edit logic
5
5
  * used when not in patch mode.
6
6
  */
7
- import { isEnoent } from "@oh-my-pi/pi-utils";
8
7
  import * as Diff from "diff";
9
8
  import { resolveToCwd } from "../tools/path-utils";
10
9
  import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
11
10
  import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
11
+ import { readEditFileText } from "./read-file";
12
12
 
13
13
  export interface DiffResult {
14
14
  diff: string;
@@ -275,17 +275,6 @@ function formatOccurrenceMatchError(
275
275
  return `Found ${occurrences} occurrences${pathSuffix}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`;
276
276
  }
277
277
 
278
- async function readFileTextForDiff(path: string, absolutePath: string): Promise<string> {
279
- try {
280
- return await Bun.file(absolutePath).text();
281
- } catch (error) {
282
- if (isEnoent(error)) {
283
- throw new Error(`File not found: ${path}`);
284
- }
285
- throw error;
286
- }
287
- }
288
-
289
278
  export function normalizeDiff(diff: string): string {
290
279
  let lines = diff.split("\n");
291
280
 
@@ -761,12 +750,12 @@ export async function computeEditDiff(
761
750
  if (oldText.length === 0) {
762
751
  return { error: "oldText must not be empty." };
763
752
  }
764
- const absolutePath = resolveToCwd(path, cwd);
765
753
 
766
754
  try {
755
+ const absolutePath = resolveToCwd(path, cwd);
767
756
  let rawContent: string;
768
757
  try {
769
- rawContent = await readFileTextForDiff(path, absolutePath);
758
+ rawContent = await readEditFileText(absolutePath, path);
770
759
  } catch (error) {
771
760
  const message = error instanceof Error ? error.message : String(error);
772
761
  return { error: message || `Unable to read ${path}` };
package/src/edit/index.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type WritethroughDeferredHandle,
9
9
  writethroughNoop,
10
10
  } from "../lsp";
11
+ import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
11
12
  import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" };
12
13
  import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
13
14
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
@@ -16,6 +17,13 @@ import type { ToolSession } from "../tools";
16
17
  import { VimTool, vimSchema } from "../tools/vim";
17
18
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
18
19
  import type { VimToolDetails } from "../vim/types";
20
+ import {
21
+ type ApplyPatchParams,
22
+ applyPatchSchema,
23
+ expandApplyPatchToEntries,
24
+ isApplyPatchParams,
25
+ } from "./modes/apply-patch";
26
+ import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
19
27
  import {
20
28
  type ChunkParams,
21
29
  type ChunkToolEdit,
@@ -50,8 +58,10 @@ import {
50
58
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
51
59
 
52
60
  export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
61
+ export * from "./apply-patch";
53
62
  export * from "./diff";
54
63
  export * from "./line-hash";
64
+ export * from "./modes/apply-patch";
55
65
  export * from "./modes/chunk";
56
66
  export * from "./modes/hashline";
57
67
  export * from "./modes/patch";
@@ -64,10 +74,11 @@ type TInput =
64
74
  | typeof patchEditSchema
65
75
  | typeof hashlineEditParamsSchema
66
76
  | typeof chunkEditParamsSchema
67
- | typeof vimSchema;
77
+ | typeof vimSchema
78
+ | typeof applyPatchSchema;
68
79
 
69
80
  type VimParams = Static<typeof vimSchema>;
70
- type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams;
81
+ type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams | ApplyPatchParams;
71
82
  type EditToolResultDetails = EditToolDetails | VimToolDetails;
72
83
 
73
84
  type EditModeDefinition = {
@@ -265,6 +276,28 @@ export class EditTool implements AgentTool<TInput> {
265
276
  return this.#getModeDefinition().parameters;
266
277
  }
267
278
 
279
+ /**
280
+ * When in `apply_patch` mode, expose the Codex Lark grammar so providers
281
+ * that support OpenAI-style custom tools can emit a grammar-constrained
282
+ * variant. Providers that don't support custom tools ignore this field
283
+ * and fall back to emitting a JSON function tool from `parameters`.
284
+ */
285
+ get customFormat(): { syntax: "lark"; definition: string } | undefined {
286
+ if (this.mode !== "apply_patch") return undefined;
287
+ return { syntax: "lark", definition: applyPatchGrammar };
288
+ }
289
+
290
+ /**
291
+ * Wire-level tool name used when the custom-tool variant is active. GPT-5+
292
+ * is trained on the literal name `apply_patch`; internally this is just a
293
+ * mode of the `edit` tool. The agent-loop dispatcher matches both the
294
+ * internal `name` and `customWireName`, so returned calls route correctly.
295
+ */
296
+ get customWireName(): string | undefined {
297
+ if (this.mode !== "apply_patch") return undefined;
298
+ return "apply_patch";
299
+ }
300
+
268
301
  async execute(
269
302
  _toolCallId: string,
270
303
  params: EditParams,
@@ -346,6 +379,36 @@ export class EditTool implements AgentTool<TInput> {
346
379
  return executePerFile(entries, batchRequest, onUpdate);
347
380
  },
348
381
  },
382
+ apply_patch: {
383
+ description: () => prompt.render(applyPatchDescription),
384
+ parameters: applyPatchSchema,
385
+ invalidParamsMessage: "Invalid edit parameters for apply_patch mode.",
386
+ validate: isApplyPatchParams,
387
+ execute: (
388
+ tool: EditTool,
389
+ params: EditParams,
390
+ signal: AbortSignal | undefined,
391
+ batchRequest: LspBatchRequest | undefined,
392
+ onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
393
+ ) => {
394
+ const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
395
+ const perFile = entries.map((entry: PatchEditEntry) => ({
396
+ path: entry.path,
397
+ run: (br: LspBatchRequest | undefined) =>
398
+ executePatchSingle({
399
+ session: tool.session,
400
+ params: entry,
401
+ signal,
402
+ batchRequest: br,
403
+ allowFuzzy: tool.#allowFuzzy,
404
+ fuzzyThreshold: tool.#fuzzyThreshold,
405
+ writethrough: tool.#writethrough,
406
+ beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
407
+ }),
408
+ }));
409
+ return executePerFile(perFile, batchRequest, onUpdate);
410
+ },
411
+ },
349
412
  hashline: {
350
413
  description: () => prompt.render(hashlineDescription),
351
414
  parameters: hashlineEditParamsSchema,