@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.2

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 (235) hide show
  1. package/CHANGELOG.md +316 -1
  2. package/package.json +86 -24
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +116 -30
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +123 -178
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -8
  28. package/src/commit/agentic/index.ts +22 -26
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  31. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  32. package/src/commit/agentic/tools/git-overview.ts +6 -9
  33. package/src/commit/agentic/tools/index.ts +6 -8
  34. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  35. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  36. package/src/commit/agentic/tools/split-commit.ts +4 -4
  37. package/src/commit/agentic/validation.ts +1 -1
  38. package/src/commit/analysis/conventional.ts +4 -4
  39. package/src/commit/analysis/summary.ts +3 -3
  40. package/src/commit/changelog/generate.ts +4 -4
  41. package/src/commit/changelog/index.ts +5 -9
  42. package/src/commit/map-reduce/map-phase.ts +4 -4
  43. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  44. package/src/commit/pipeline.ts +13 -16
  45. package/src/config/keybindings.ts +7 -6
  46. package/src/config/prompt-templates.ts +44 -226
  47. package/src/config/resolve-config-value.ts +4 -2
  48. package/src/config/settings-schema.ts +98 -2
  49. package/src/config/settings.ts +25 -26
  50. package/src/dap/client.ts +674 -0
  51. package/src/dap/config.ts +150 -0
  52. package/src/dap/defaults.json +211 -0
  53. package/src/dap/index.ts +4 -0
  54. package/src/dap/session.ts +1255 -0
  55. package/src/dap/types.ts +600 -0
  56. package/src/debug/log-viewer.ts +3 -2
  57. package/src/discovery/builtin.ts +1 -2
  58. package/src/discovery/codex.ts +2 -2
  59. package/src/discovery/github.ts +2 -1
  60. package/src/discovery/helpers.ts +2 -2
  61. package/src/discovery/opencode.ts +2 -2
  62. package/src/edit/diff.ts +818 -0
  63. package/src/edit/index.ts +309 -0
  64. package/src/edit/line-hash.ts +67 -0
  65. package/src/edit/modes/chunk.ts +454 -0
  66. package/src/{patch → edit/modes}/hashline.ts +741 -361
  67. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  68. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  69. package/src/{patch → edit}/normalize.ts +97 -76
  70. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  71. package/src/exec/bash-executor.ts +4 -2
  72. package/src/exec/idle-timeout-watchdog.ts +126 -0
  73. package/src/exec/non-interactive-env.ts +5 -0
  74. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
  75. package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
  76. package/src/extensibility/custom-commands/loader.ts +1 -2
  77. package/src/extensibility/custom-tools/loader.ts +34 -11
  78. package/src/extensibility/custom-tools/types.ts +1 -1
  79. package/src/extensibility/extensions/loader.ts +9 -4
  80. package/src/extensibility/extensions/runner.ts +24 -1
  81. package/src/extensibility/extensions/types.ts +4 -2
  82. package/src/extensibility/hooks/loader.ts +5 -6
  83. package/src/extensibility/hooks/types.ts +2 -2
  84. package/src/extensibility/plugins/doctor.ts +2 -1
  85. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  86. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  87. package/src/extensibility/slash-commands.ts +3 -7
  88. package/src/index.ts +3 -1
  89. package/src/internal-urls/docs-index.generated.ts +11 -11
  90. package/src/ipy/executor.ts +58 -17
  91. package/src/ipy/gateway-coordinator.ts +6 -4
  92. package/src/ipy/kernel.ts +45 -22
  93. package/src/ipy/runtime.ts +2 -2
  94. package/src/lsp/client.ts +7 -4
  95. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  96. package/src/lsp/config.ts +2 -2
  97. package/src/lsp/defaults.json +688 -154
  98. package/src/lsp/index.ts +234 -45
  99. package/src/lsp/lspmux.ts +2 -2
  100. package/src/lsp/startup-events.ts +13 -0
  101. package/src/lsp/types.ts +12 -1
  102. package/src/lsp/utils.ts +8 -1
  103. package/src/main.ts +125 -47
  104. package/src/memories/index.ts +4 -5
  105. package/src/modes/acp/acp-agent.ts +563 -163
  106. package/src/modes/acp/acp-event-mapper.ts +9 -1
  107. package/src/modes/acp/acp-mode.ts +4 -2
  108. package/src/modes/components/agent-dashboard.ts +3 -4
  109. package/src/modes/components/diff.ts +6 -7
  110. package/src/modes/components/footer.ts +9 -29
  111. package/src/modes/components/hook-editor.ts +3 -3
  112. package/src/modes/components/hook-selector.ts +6 -1
  113. package/src/modes/components/read-tool-group.ts +6 -12
  114. package/src/modes/components/session-observer-overlay.ts +472 -0
  115. package/src/modes/components/settings-defs.ts +24 -0
  116. package/src/modes/components/status-line.ts +15 -61
  117. package/src/modes/components/tool-execution.ts +1 -1
  118. package/src/modes/components/welcome.ts +1 -1
  119. package/src/modes/controllers/btw-controller.ts +2 -2
  120. package/src/modes/controllers/command-controller.ts +4 -2
  121. package/src/modes/controllers/event-controller.ts +59 -2
  122. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  123. package/src/modes/controllers/input-controller.ts +15 -8
  124. package/src/modes/controllers/selector-controller.ts +26 -0
  125. package/src/modes/index.ts +20 -2
  126. package/src/modes/interactive-mode.ts +278 -69
  127. package/src/modes/rpc/host-tools.ts +186 -0
  128. package/src/modes/rpc/rpc-client.ts +178 -13
  129. package/src/modes/rpc/rpc-mode.ts +73 -3
  130. package/src/modes/rpc/rpc-types.ts +53 -1
  131. package/src/modes/session-observer-registry.ts +146 -0
  132. package/src/modes/shared.ts +0 -42
  133. package/src/modes/theme/theme.ts +80 -8
  134. package/src/modes/types.ts +4 -2
  135. package/src/modes/utils/keybinding-matchers.ts +9 -0
  136. package/src/prompts/system/custom-system-prompt.md +5 -0
  137. package/src/prompts/system/system-prompt.md +8 -1
  138. package/src/prompts/tools/chunk-edit.md +219 -0
  139. package/src/prompts/tools/debug.md +43 -0
  140. package/src/prompts/tools/grep.md +3 -0
  141. package/src/prompts/tools/lsp.md +5 -5
  142. package/src/prompts/tools/read-chunk.md +17 -0
  143. package/src/prompts/tools/read.md +19 -5
  144. package/src/sdk.ts +216 -165
  145. package/src/secrets/index.ts +1 -1
  146. package/src/secrets/obfuscator.ts +25 -17
  147. package/src/session/agent-session.ts +381 -286
  148. package/src/session/agent-storage.ts +12 -12
  149. package/src/session/compaction/branch-summarization.ts +3 -3
  150. package/src/session/compaction/compaction.ts +5 -6
  151. package/src/session/compaction/utils.ts +3 -3
  152. package/src/session/history-storage.ts +62 -19
  153. package/src/session/messages.ts +3 -3
  154. package/src/session/session-dump-format.ts +203 -0
  155. package/src/session/session-manager.ts +15 -5
  156. package/src/session/session-storage.ts +4 -2
  157. package/src/session/streaming-output.ts +1 -1
  158. package/src/session/tool-choice-queue.ts +213 -0
  159. package/src/slash-commands/builtin-registry.ts +56 -8
  160. package/src/ssh/connection-manager.ts +2 -2
  161. package/src/ssh/sshfs-mount.ts +5 -5
  162. package/src/stt/downloader.ts +4 -4
  163. package/src/stt/recorder.ts +4 -4
  164. package/src/stt/transcriber.ts +2 -2
  165. package/src/system-prompt.ts +25 -13
  166. package/src/task/agents.ts +5 -6
  167. package/src/task/commands.ts +2 -5
  168. package/src/task/executor.ts +32 -4
  169. package/src/task/index.ts +91 -82
  170. package/src/task/template.ts +2 -2
  171. package/src/task/types.ts +25 -0
  172. package/src/task/worktree.ts +131 -149
  173. package/src/tools/ask.ts +2 -3
  174. package/src/tools/ast-edit.ts +7 -7
  175. package/src/tools/ast-grep.ts +7 -7
  176. package/src/tools/auto-generated-guard.ts +36 -41
  177. package/src/tools/await-tool.ts +2 -2
  178. package/src/tools/bash.ts +5 -23
  179. package/src/tools/browser.ts +4 -5
  180. package/src/tools/calculator.ts +2 -3
  181. package/src/tools/cancel-job.ts +2 -2
  182. package/src/tools/checkpoint.ts +3 -3
  183. package/src/tools/debug.ts +1007 -0
  184. package/src/tools/exit-plan-mode.ts +3 -3
  185. package/src/tools/fetch.ts +67 -3
  186. package/src/tools/find.ts +4 -5
  187. package/src/tools/fs-cache-invalidation.ts +5 -0
  188. package/src/tools/gemini-image.ts +13 -5
  189. package/src/tools/gh.ts +130 -308
  190. package/src/tools/grep.ts +57 -9
  191. package/src/tools/index.ts +44 -22
  192. package/src/tools/inspect-image.ts +4 -4
  193. package/src/tools/output-meta.ts +1 -1
  194. package/src/tools/python.ts +19 -6
  195. package/src/tools/read.ts +211 -146
  196. package/src/tools/render-mermaid.ts +2 -3
  197. package/src/tools/render-utils.ts +20 -6
  198. package/src/tools/renderers.ts +3 -1
  199. package/src/tools/report-tool-issue.ts +80 -0
  200. package/src/tools/resolve.ts +70 -39
  201. package/src/tools/search-tool-bm25.ts +2 -2
  202. package/src/tools/ssh.ts +2 -2
  203. package/src/tools/todo-write.ts +2 -2
  204. package/src/tools/tool-timeouts.ts +1 -0
  205. package/src/tools/write.ts +5 -6
  206. package/src/tui/tree-list.ts +3 -1
  207. package/src/utils/clipboard.ts +80 -0
  208. package/src/utils/commit-message-generator.ts +2 -3
  209. package/src/utils/edit-mode.ts +49 -0
  210. package/src/utils/external-editor.ts +11 -5
  211. package/src/utils/file-display-mode.ts +6 -5
  212. package/src/utils/file-mentions.ts +8 -7
  213. package/src/utils/git.ts +1400 -0
  214. package/src/utils/image-loading.ts +98 -0
  215. package/src/utils/title-generator.ts +2 -3
  216. package/src/utils/tools-manager.ts +6 -6
  217. package/src/web/scrapers/choosealicense.ts +1 -1
  218. package/src/web/search/index.ts +3 -3
  219. package/src/web/search/render.ts +6 -4
  220. package/src/autoresearch/command-initialize.md +0 -34
  221. package/src/commit/git/errors.ts +0 -9
  222. package/src/commit/git/index.ts +0 -210
  223. package/src/commit/git/operations.ts +0 -54
  224. package/src/patch/diff.ts +0 -433
  225. package/src/patch/index.ts +0 -888
  226. package/src/patch/parser.ts +0 -532
  227. package/src/patch/types.ts +0 -292
  228. package/src/prompts/agents/oracle.md +0 -77
  229. package/src/tools/gh-cli.ts +0 -125
  230. package/src/tools/pending-action.ts +0 -49
  231. package/src/utils/child-process.ts +0 -88
  232. package/src/utils/frontmatter.ts +0 -117
  233. package/src/utils/image-input.ts +0 -274
  234. package/src/utils/mime.ts +0 -53
  235. package/src/utils/prompt-format.ts +0 -170
@@ -1,10 +1,11 @@
1
1
  import type { Dirent } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
- import path from "node:path";
4
+ import * as path from "node:path";
5
5
  import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives";
6
- import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils";
6
+ import { $which, getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import { $ } from "bun";
8
+ import * as git from "../utils/git";
8
9
 
9
10
  /** Baseline state for a single git repository. */
10
11
  export interface RepoBaseline {
@@ -27,14 +28,11 @@ export function getEncodedProjectName(cwd: string): string {
27
28
  }
28
29
 
29
30
  export async function getRepoRoot(cwd: string): Promise<string> {
30
- const result = await $`git rev-parse --show-toplevel`.cwd(cwd).quiet().nothrow();
31
- if (result.exitCode !== 0) {
32
- throw new Error("Git repository not found for isolated task execution.");
33
- }
34
- const repoRoot = result.text().trim();
31
+ const repoRoot = await git.repo.root(cwd);
35
32
  if (!repoRoot) {
36
- throw new Error("Git repository root could not be resolved for isolated task execution.");
33
+ throw new Error("Git repository not found for isolated task execution.");
37
34
  }
35
+
38
36
  return repoRoot;
39
37
  }
40
38
 
@@ -54,26 +52,16 @@ export async function ensureWorktree(baseCwd: string, id: string): Promise<strin
54
52
  const encodedProject = getEncodedProjectName(repoRoot);
55
53
  const worktreeDir = getWorktreeDir(encodedProject, id);
56
54
  await fs.mkdir(path.dirname(worktreeDir), { recursive: true });
57
- await $`git worktree remove -f ${worktreeDir}`.cwd(repoRoot).quiet().nothrow();
55
+ await git.worktree.tryRemove(repoRoot, worktreeDir);
58
56
  await fs.rm(worktreeDir, { recursive: true, force: true });
59
- await $`git worktree add --detach ${worktreeDir} HEAD`.cwd(repoRoot).quiet();
57
+ await git.worktree.add(repoRoot, worktreeDir, "HEAD", { detach: true });
60
58
  return worktreeDir;
61
59
  }
62
60
 
63
61
  /** Find nested git repositories (non-submodule) under the given root. */
64
62
  async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
65
63
  // Get submodule paths so we can exclude them
66
- const submoduleRaw = await $`git submodule --quiet foreach --recursive 'echo $sm_path'`
67
- .cwd(repoRoot)
68
- .quiet()
69
- .nothrow()
70
- .text();
71
- const submodulePaths = new Set(
72
- submoduleRaw
73
- .split("\n")
74
- .map(l => l.trim())
75
- .filter(Boolean),
76
- );
64
+ const submodulePaths = new Set(await git.ls.submodules(repoRoot));
77
65
 
78
66
  // Find all .git dirs/files that aren't the root or known submodules
79
67
  const result: string[] = [];
@@ -109,14 +97,10 @@ async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
109
97
  }
110
98
 
111
99
  async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
112
- const headCommit = (await $`git rev-parse HEAD`.cwd(repoRoot).quiet().text()).trim();
113
- const staged = await $`git diff --cached --binary`.cwd(repoRoot).quiet().text();
114
- const unstaged = await $`git diff --binary`.cwd(repoRoot).quiet().text();
115
- const untrackedRaw = await $`git ls-files --others --exclude-standard`.cwd(repoRoot).quiet().text();
116
- const untracked = untrackedRaw
117
- .split("\n")
118
- .map(line => line.trim())
119
- .filter(line => line.length > 0);
100
+ const headCommit = (await git.head.sha(repoRoot)) ?? "";
101
+ const staged = await git.diff(repoRoot, { binary: true, cached: true });
102
+ const unstaged = await git.diff(repoRoot, { binary: true });
103
+ const untracked = await git.ls.untracked(repoRoot);
120
104
  return { repoRoot, headCommit, staged, unstaged, untracked };
121
105
  }
122
106
 
@@ -131,35 +115,10 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
131
115
  return { root, nested };
132
116
  }
133
117
 
134
- async function writeTempPatchFile(patch: string): Promise<string> {
135
- const tempPath = path.join(os.tmpdir(), `omp-task-patch-${Snowflake.next()}.patch`);
136
- await Bun.write(tempPath, patch);
137
- return tempPath;
138
- }
139
-
140
- async function applyPatch(
141
- cwd: string,
142
- patch: string,
143
- options?: { cached?: boolean; env?: Record<string, string> },
144
- ): Promise<void> {
145
- if (!patch.trim()) return;
146
- const tempPath = await writeTempPatchFile(patch);
147
- try {
148
- const command = options?.cached ? $`git apply --cached --binary ${tempPath}` : $`git apply --binary ${tempPath}`;
149
- let runner = command.cwd(cwd).quiet();
150
- if (options?.env) {
151
- runner = runner.env(options.env);
152
- }
153
- await runner;
154
- } finally {
155
- await fs.rm(tempPath, { force: true });
156
- }
157
- }
158
-
159
118
  async function applyRepoBaseline(worktreeDir: string, rb: RepoBaseline, sourceRoot: string): Promise<void> {
160
- await applyPatch(worktreeDir, rb.staged, { cached: true });
161
- await applyPatch(worktreeDir, rb.staged);
162
- await applyPatch(worktreeDir, rb.unstaged);
119
+ await git.patch.applyText(worktreeDir, rb.staged, { cached: true });
120
+ await git.patch.applyText(worktreeDir, rb.staged);
121
+ await git.patch.applyText(worktreeDir, rb.unstaged);
163
122
 
164
123
  for (const entry of rb.untracked) {
165
124
  const source = path.join(sourceRoot, entry);
@@ -193,15 +152,12 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel
193
152
  // Commit baseline state so captureRepoDeltaPatch can cleanly subtract it.
194
153
  // Without this, `git add -A && git commit` by the task would include
195
154
  // baseline untracked files in the diff-tree output.
196
- const hasChanges = (
197
- await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text()
198
- ).trim();
199
- if (hasChanges) {
200
- await $`git add -A`.cwd(nestedDir).quiet();
201
- await $`git commit -m omp-baseline --allow-empty`.cwd(nestedDir).quiet();
155
+ if ((await git.status(nestedDir)).trim().length > 0) {
156
+ await git.stage.files(nestedDir);
157
+ await git.commit(nestedDir, "omp-baseline", { allowEmpty: true });
202
158
  // Update baseline to reflect the committed state — prevents double-apply
203
159
  // in captureRepoDeltaPatch's temp-index path
204
- entry.baseline.headCommit = (await $`git rev-parse HEAD`.cwd(nestedDir).quiet().text()).trim();
160
+ entry.baseline.headCommit = (await git.head.sha(nestedDir)) ?? "";
205
161
  entry.baseline.staged = "";
206
162
  entry.baseline.unstaged = "";
207
163
  entry.baseline.untracked = [];
@@ -209,32 +165,9 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel
209
165
  }
210
166
  }
211
167
 
212
- async function applyPatchToIndex(cwd: string, patch: string, indexFile: string): Promise<void> {
213
- if (!patch.trim()) return;
214
- const tempPath = await writeTempPatchFile(patch);
215
- try {
216
- await $`git apply --cached --binary ${tempPath}`
217
- .cwd(cwd)
218
- .env({
219
- GIT_INDEX_FILE: indexFile,
220
- })
221
- .quiet();
222
- } finally {
223
- await fs.rm(tempPath, { force: true });
224
- }
225
- }
226
-
227
- async function listUntracked(cwd: string): Promise<string[]> {
228
- const raw = await $`git ls-files --others --exclude-standard`.cwd(cwd).quiet().text();
229
- return raw
230
- .split("\n")
231
- .map(line => line.trim())
232
- .filter(line => line.length > 0);
233
- }
234
-
235
168
  async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
236
169
  // Check if HEAD advanced (task committed changes)
237
- const currentHead = (await $`git rev-parse HEAD`.cwd(repoDir).quiet().nothrow().text()).trim();
170
+ const currentHead = (await git.head.sha(repoDir)) ?? "";
238
171
  const headAdvanced = currentHead && currentHead !== rb.headCommit;
239
172
 
240
173
  if (headAdvanced) {
@@ -242,28 +175,31 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise
242
175
  const parts: string[] = [];
243
176
 
244
177
  // Committed changes since baseline
245
- const committedDiff = await $`git diff-tree -r -p --binary ${rb.headCommit} ${currentHead}`
246
- .cwd(repoDir)
247
- .quiet()
248
- .nothrow()
249
- .text();
178
+ const committedDiff = await git.diff.tree(repoDir, rb.headCommit, currentHead, {
179
+ allowFailure: true,
180
+ binary: true,
181
+ });
250
182
  if (committedDiff.trim()) parts.push(committedDiff);
251
183
 
252
184
  // Uncommitted changes on top of the new HEAD
253
- const staged = await $`git diff --cached --binary`.cwd(repoDir).quiet().text();
254
- const unstaged = await $`git diff --binary`.cwd(repoDir).quiet().text();
185
+ const staged = await git.diff(repoDir, { binary: true, cached: true });
186
+ const unstaged = await git.diff(repoDir, { binary: true });
255
187
  if (staged.trim()) parts.push(staged);
256
188
  if (unstaged.trim()) parts.push(unstaged);
257
189
 
258
190
  // New untracked files (relative to both baseline and current tracking)
259
- const currentUntracked = await listUntracked(repoDir);
191
+ const currentUntracked = await git.ls.untracked(repoDir);
260
192
  const baselineUntracked = new Set(rb.untracked);
261
193
  const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
262
194
  if (newUntracked.length > 0) {
263
195
  const nullPath = getGitNoIndexNullPath();
264
196
  const untrackedDiffs = await Promise.all(
265
197
  newUntracked.map(entry =>
266
- $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(),
198
+ git.diff(repoDir, {
199
+ allowFailure: true,
200
+ binary: true,
201
+ noIndex: { left: nullPath, right: entry },
202
+ }),
267
203
  ),
268
204
  );
269
205
  parts.push(...untrackedDiffs.filter(d => d.trim()));
@@ -275,12 +211,23 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise
275
211
  // HEAD unchanged: use temp index approach (subtracts baseline from delta)
276
212
  const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
277
213
  try {
278
- await $`git read-tree ${rb.headCommit}`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex });
279
- await applyPatchToIndex(repoDir, rb.staged, tempIndex);
280
- await applyPatchToIndex(repoDir, rb.unstaged, tempIndex);
281
- const diff = await $`git diff --binary`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex }).quiet().text();
282
-
283
- const currentUntracked = await listUntracked(repoDir);
214
+ await git.readTree(repoDir, rb.headCommit, {
215
+ env: { GIT_INDEX_FILE: tempIndex },
216
+ });
217
+ await git.patch.applyText(repoDir, rb.staged, {
218
+ cached: true,
219
+ env: { GIT_INDEX_FILE: tempIndex },
220
+ });
221
+ await git.patch.applyText(repoDir, rb.unstaged, {
222
+ cached: true,
223
+ env: { GIT_INDEX_FILE: tempIndex },
224
+ });
225
+ const diff = await git.diff(repoDir, {
226
+ binary: true,
227
+ env: { GIT_INDEX_FILE: tempIndex },
228
+ });
229
+
230
+ const currentUntracked = await git.ls.untracked(repoDir);
284
231
  const baselineUntracked = new Set(rb.untracked);
285
232
  const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
286
233
 
@@ -289,7 +236,11 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise
289
236
  const nullPath = getGitNoIndexNullPath();
290
237
  const untrackedDiffs = await Promise.all(
291
238
  newUntracked.map(entry =>
292
- $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(),
239
+ git.diff(repoDir, {
240
+ allowFailure: true,
241
+ binary: true,
242
+ noIndex: { left: nullPath, right: entry },
243
+ }),
293
244
  ),
294
245
  );
295
246
  return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
@@ -355,29 +306,25 @@ export async function applyNestedPatches(
355
306
 
356
307
  const combinedDiff = repoPatches.map(p => p.patch).join("\n");
357
308
  for (const { patch } of repoPatches) {
358
- await applyPatch(nestedDir, patch);
309
+ await git.patch.applyText(nestedDir, patch);
359
310
  }
360
311
 
361
312
  // Commit so nested repo history reflects the task changes
362
- const hasChanges = (
363
- await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text()
364
- ).trim();
365
- if (hasChanges) {
313
+ if ((await git.status(nestedDir)).trim().length > 0) {
366
314
  const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
367
- await $`git add -A`.cwd(nestedDir).quiet();
368
- await $`git commit -m ${msg}`.cwd(nestedDir).quiet();
315
+ await git.stage.files(nestedDir);
316
+ await git.commit(nestedDir, msg);
369
317
  }
370
318
  }
371
319
  }
372
320
 
373
321
  export async function cleanupWorktree(dir: string): Promise<void> {
374
322
  try {
375
- const commonDirRaw = await $`git rev-parse --git-common-dir`.cwd(dir).quiet().nothrow().text();
376
- const commonDir = commonDirRaw.trim();
377
- if (commonDir) {
378
- const resolvedCommon = path.resolve(dir, commonDir);
379
- const repoRoot = path.dirname(resolvedCommon);
380
- await $`git worktree remove -f ${dir}`.cwd(repoRoot).quiet().nothrow();
323
+ const repository = await git.repo.resolve(dir);
324
+ const commonDir = repository?.commonDir ?? "";
325
+ if (commonDir && path.basename(commonDir) === ".git") {
326
+ const repoRoot = path.dirname(commonDir);
327
+ await git.worktree.tryRemove(repoRoot, dir);
381
328
  }
382
329
  } finally {
383
330
  await fs.rm(dir, { recursive: true, force: true });
@@ -401,7 +348,7 @@ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<st
401
348
  const mergedDir = path.join(baseDir, "merged");
402
349
 
403
350
  // Clean up any stale mount at this path (linux only)
404
- const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
351
+ const fusermount = $which("fusermount3") ?? $which("fusermount");
405
352
  if (fusermount) {
406
353
  await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
407
354
  }
@@ -411,7 +358,7 @@ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<st
411
358
  await fs.mkdir(workDir, { recursive: true });
412
359
  await fs.mkdir(mergedDir, { recursive: true });
413
360
 
414
- const binary = Bun.which("fuse-overlayfs");
361
+ const binary = $which("fuse-overlayfs");
415
362
  if (!binary) {
416
363
  await fs.rm(baseDir, { recursive: true, force: true });
417
364
  throw new Error(
@@ -433,7 +380,7 @@ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<st
433
380
 
434
381
  export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
435
382
  try {
436
- const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
383
+ const fusermount = $which("fusermount3") ?? $which("fusermount");
437
384
  if (fusermount) {
438
385
  await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
439
386
  }
@@ -518,33 +465,31 @@ export async function commitToBranch(
518
465
 
519
466
  // Only create a branch if the root repo has changes
520
467
  if (rootPatch.trim()) {
521
- await $`git branch ${branchName} HEAD`.cwd(repoRoot).quiet();
468
+ await git.branch.create(repoRoot, branchName);
522
469
  const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
523
470
  try {
524
- await $`git worktree add ${tmpDir} ${branchName}`.cwd(repoRoot).quiet();
525
- const patchPath = path.join(os.tmpdir(), `omp-branch-patch-${Snowflake.next()}.patch`);
471
+ await git.worktree.add(repoRoot, tmpDir, branchName);
526
472
  try {
527
- await Bun.write(patchPath, rootPatch);
528
- const applyResult = await $`git apply --binary ${patchPath}`.cwd(tmpDir).quiet().nothrow();
529
- if (applyResult.exitCode !== 0) {
530
- const stderr = applyResult.stderr.toString().slice(0, 2000);
473
+ await git.patch.applyText(tmpDir, rootPatch);
474
+ } catch (err) {
475
+ if (err instanceof git.GitCommandError) {
476
+ const stderr = err.result.stderr.slice(0, 2000);
531
477
  logger.error("commitToBranch: git apply failed", {
532
478
  taskId,
533
- exitCode: applyResult.exitCode,
479
+ exitCode: err.result.exitCode,
534
480
  stderr,
535
481
  patchSize: rootPatch.length,
536
482
  patchHead: rootPatch.slice(0, 500),
537
483
  });
538
484
  throw new Error(`git apply failed for task ${taskId}: ${stderr}`);
539
485
  }
540
- } finally {
541
- await fs.rm(patchPath, { force: true });
486
+ throw err;
542
487
  }
543
- await $`git add -A`.cwd(tmpDir).quiet();
488
+ await git.stage.files(tmpDir);
544
489
  const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
545
- await $`git commit -m ${msg}`.cwd(tmpDir).quiet();
490
+ await git.commit(tmpDir, msg);
546
491
  } finally {
547
- await $`git worktree remove -f ${tmpDir}`.cwd(repoRoot).quiet().nothrow();
492
+ await git.worktree.tryRemove(repoRoot, tmpDir);
548
493
  await fs.rm(tmpDir, { recursive: true, force: true });
549
494
  }
550
495
  }
@@ -570,29 +515,66 @@ export async function mergeTaskBranches(
570
515
  const merged: string[] = [];
571
516
  const failed: string[] = [];
572
517
 
573
- for (const { branchName } of branches) {
574
- const result = await $`git cherry-pick ${branchName}`.cwd(repoRoot).quiet().nothrow();
575
-
576
- if (result.exitCode !== 0) {
577
- await $`git cherry-pick --abort`.cwd(repoRoot).quiet().nothrow();
578
- const stderr = result.stderr.toString().trim();
579
- failed.push(branchName);
580
- return {
581
- merged,
582
- failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
583
- conflict: `${branchName}: ${stderr}`,
584
- };
585
- }
518
+ // Stash dirty working tree so cherry-pick can operate on a clean HEAD.
519
+ // Without this, cherry-pick refuses to run when uncommitted changes exist.
520
+ const didStash = await git.stash.push(repoRoot, "omp-task-merge");
521
+
522
+ let conflictResult: MergeBranchResult | undefined;
586
523
 
587
- merged.push(branchName);
524
+ try {
525
+ for (const { branchName } of branches) {
526
+ try {
527
+ await git.cherryPick(repoRoot, branchName);
528
+ } catch (err) {
529
+ try {
530
+ await git.cherryPick.abort(repoRoot);
531
+ } catch {
532
+ /* no state to abort */
533
+ }
534
+ const stderr =
535
+ err instanceof git.GitCommandError
536
+ ? err.result.stderr.trim()
537
+ : err instanceof Error
538
+ ? err.message
539
+ : String(err);
540
+ failed.push(branchName);
541
+ conflictResult = {
542
+ merged,
543
+ failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
544
+ conflict: `${branchName}: ${stderr}`,
545
+ };
546
+ break;
547
+ }
548
+
549
+ merged.push(branchName);
550
+ }
551
+ } finally {
552
+ if (didStash) {
553
+ try {
554
+ await git.stash.pop(repoRoot, { index: true });
555
+ } catch {
556
+ // Stash-pop conflicts mean the replayed changes clash with the user's
557
+ // uncommitted edits. Treat this as a merge failure so the caller preserves
558
+ // recovery branches instead of reporting success and deleting them.
559
+ logger.warn("Failed to restore stashed changes after task merge; stash entry preserved");
560
+ if (!conflictResult) {
561
+ conflictResult = {
562
+ merged,
563
+ failed: merged,
564
+ conflict:
565
+ "stash pop: cherry-picked changes conflict with uncommitted edits. Run `git stash pop` and resolve manually.",
566
+ };
567
+ }
568
+ }
569
+ }
588
570
  }
589
571
 
590
- return { merged, failed };
572
+ return conflictResult ?? { merged, failed };
591
573
  }
592
574
 
593
575
  /** Clean up temporary task branches. */
594
576
  export async function cleanupTaskBranches(repoRoot: string, branches: string[]): Promise<void> {
595
577
  for (const branch of branches) {
596
- await $`git branch -D ${branch}`.cwd(repoRoot).quiet().nothrow();
578
+ await git.branch.tryDelete(repoRoot, branch);
597
579
  }
598
580
  }
package/src/tools/ask.ts CHANGED
@@ -17,9 +17,8 @@
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
20
- import { untilAborted } from "@oh-my-pi/pi-utils";
20
+ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
21
21
  import { type Static, Type } from "@sinclair/typebox";
22
- import { renderPromptTemplate } from "../config/prompt-templates";
23
22
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
24
23
  import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
25
24
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
@@ -385,7 +384,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
385
384
  readonly strict = true;
386
385
 
387
386
  constructor(private readonly session: ToolSession) {
388
- this.description = renderPromptTemplate(askDescription);
387
+ this.description = prompt.render(askDescription);
389
388
  }
390
389
 
391
390
  static createIf(session: ToolSession): AskTool | null {
@@ -3,12 +3,11 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
3
3
  import { type AstReplaceChange, astEdit } from "@oh-my-pi/pi-natives";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
- import { renderPromptTemplate } from "../config/prompt-templates";
8
+ import { computeLineHash } from "../edit/line-hash";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { Theme } from "../modes/theme/theme";
11
- import { computeLineHash } from "../patch/hashline";
12
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
13
12
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
@@ -31,6 +30,7 @@ import {
31
30
  PARSE_ERRORS_LIMIT,
32
31
  PREVIEW_LIMITS,
33
32
  } from "./render-utils";
33
+ import { queueResolveHandler } from "./resolve";
34
34
  import { ToolError } from "./tool-errors";
35
35
  import { toolResult } from "./tool-result";
36
36
 
@@ -71,7 +71,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
71
71
  readonly strict = true;
72
72
  readonly deferrable = true;
73
73
  constructor(private readonly session: ToolSession) {
74
- this.description = renderPromptTemplate(astEditDescription);
74
+ this.description = prompt.render(astEditDescription);
75
75
  }
76
76
 
77
77
  async execute(
@@ -201,7 +201,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
201
201
  filesSearched: result.filesSearched,
202
202
  applied: result.applied,
203
203
  limitReached: result.limitReached,
204
- parseErrors: dedupedParseErrors,
204
+ ...(dedupedParseErrors.length > 0 ? { parseErrors: dedupedParseErrors } : {}),
205
205
  scopePath,
206
206
  files: fileList,
207
207
  fileReplacements: [],
@@ -289,7 +289,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
289
289
  if (!result.applied && result.totalReplacements > 0) {
290
290
  const previewReplacementPlural = result.totalReplacements !== 1 ? "s" : "";
291
291
  const previewFilePlural = result.filesTouched !== 1 ? "s" : "";
292
- this.session.pendingActionStore?.push({
292
+ queueResolveHandler(this.session, {
293
293
  label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
294
294
  sourceToolName: this.name,
295
295
  apply: async (_reason: string) => {
@@ -311,7 +311,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
311
311
  filesSearched: applyResult.filesSearched,
312
312
  applied: applyResult.applied,
313
313
  limitReached: applyResult.limitReached,
314
- parseErrors: dedupedApplyParseErrors,
314
+ ...(dedupedApplyParseErrors.length > 0 ? { parseErrors: dedupedApplyParseErrors } : {}),
315
315
  scopePath,
316
316
  files: fileList,
317
317
  fileReplacements,
@@ -3,12 +3,11 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
3
3
  import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
- import { renderPromptTemplate } from "../config/prompt-templates";
8
+ import { computeLineHash } from "../edit/line-hash";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { Theme } from "../modes/theme/theme";
11
- import { computeLineHash } from "../patch/hashline";
12
11
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
13
12
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
@@ -65,7 +64,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
65
64
  readonly strict = true;
66
65
 
67
66
  constructor(private readonly session: ToolSession) {
68
- this.description = renderPromptTemplate(astGrepDescription);
67
+ this.description = prompt.render(astGrepDescription);
69
68
  }
70
69
 
71
70
  async execute(
@@ -188,7 +187,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
188
187
  fileCount: result.filesWithMatches,
189
188
  filesSearched: result.filesSearched,
190
189
  limitReached: result.limitReached,
191
- parseErrors: dedupedParseErrors,
190
+ ...(dedupedParseErrors.length > 0 ? { parseErrors: dedupedParseErrors } : {}),
192
191
  scopePath,
193
192
  files: fileList,
194
193
  fileMatches: [],
@@ -213,12 +212,13 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
213
212
  const lineNumbers = matchLines.map((_, index) => match.startLine + index);
214
213
  const lineWidth = Math.max(...lineNumbers.map(value => value.toString().length));
215
214
  const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
215
+ const separator = isMatch ? ":" : "-";
216
216
  if (useHashLines) {
217
217
  const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
218
- return isMatch ? `>>${ref}:${line}` : ` ${ref}:${line}`;
218
+ return `${ref}${separator}${line}`;
219
219
  }
220
220
  const padded = lineNumber.toString().padStart(lineWidth, " ");
221
- return isMatch ? `>>${padded}:${line}` : ` ${padded}:${line}`;
221
+ return `${padded}${separator}${line}`;
222
222
  };
223
223
  for (let index = 0; index < matchLines.length; index++) {
224
224
  outputLines.push(formatLine(match.startLine + index, matchLines[index], index === 0));