@oh-my-pi/pi-coding-agent 13.18.0 → 13.19.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 (64) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -11
  3. package/src/autoresearch/git.ts +25 -30
  4. package/src/autoresearch/tools/log-experiment.ts +61 -74
  5. package/src/commit/agentic/agent.ts +0 -3
  6. package/src/commit/agentic/index.ts +19 -22
  7. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  8. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  9. package/src/commit/agentic/tools/git-overview.ts +6 -9
  10. package/src/commit/agentic/tools/index.ts +6 -8
  11. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  12. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  13. package/src/commit/agentic/tools/split-commit.ts +4 -4
  14. package/src/commit/changelog/index.ts +5 -9
  15. package/src/commit/pipeline.ts +10 -12
  16. package/src/config/keybindings.ts +7 -6
  17. package/src/config/settings-schema.ts +44 -0
  18. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
  19. package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
  20. package/src/extensibility/custom-tools/types.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -1
  22. package/src/extensibility/hooks/types.ts +1 -1
  23. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  24. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  25. package/src/index.ts +1 -0
  26. package/src/main.ts +24 -2
  27. package/src/modes/components/footer.ts +9 -29
  28. package/src/modes/components/hook-editor.ts +3 -3
  29. package/src/modes/components/hook-selector.ts +6 -1
  30. package/src/modes/components/session-observer-overlay.ts +472 -0
  31. package/src/modes/components/settings-defs.ts +19 -0
  32. package/src/modes/components/status-line.ts +15 -61
  33. package/src/modes/controllers/command-controller.ts +1 -0
  34. package/src/modes/controllers/event-controller.ts +59 -2
  35. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  36. package/src/modes/controllers/input-controller.ts +3 -0
  37. package/src/modes/controllers/selector-controller.ts +26 -0
  38. package/src/modes/interactive-mode.ts +195 -43
  39. package/src/modes/session-observer-registry.ts +146 -0
  40. package/src/modes/shared.ts +0 -42
  41. package/src/modes/types.ts +2 -0
  42. package/src/modes/utils/keybinding-matchers.ts +9 -0
  43. package/src/prompts/system/custom-system-prompt.md +5 -0
  44. package/src/prompts/system/system-prompt.md +6 -0
  45. package/src/sdk.ts +28 -13
  46. package/src/secrets/index.ts +1 -1
  47. package/src/secrets/obfuscator.ts +24 -16
  48. package/src/session/agent-session.ts +75 -30
  49. package/src/session/session-manager.ts +15 -5
  50. package/src/system-prompt.ts +4 -0
  51. package/src/task/executor.ts +28 -0
  52. package/src/task/index.ts +88 -78
  53. package/src/task/types.ts +25 -0
  54. package/src/task/worktree.ts +127 -145
  55. package/src/tools/exit-plan-mode.ts +1 -0
  56. package/src/tools/gh.ts +120 -297
  57. package/src/tools/read.ts +13 -79
  58. package/src/utils/external-editor.ts +11 -5
  59. package/src/utils/git.ts +1400 -0
  60. package/src/web/search/render.ts +6 -4
  61. package/src/commit/git/errors.ts +0 -9
  62. package/src/commit/git/index.ts +0 -210
  63. package/src/commit/git/operations.ts +0 -54
  64. package/src/tools/gh-cli.ts +0 -125
package/src/task/types.ts CHANGED
@@ -31,6 +31,31 @@ export const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
31
31
  /** EventBus channel for aggregated subagent progress */
32
32
  export const TASK_SUBAGENT_PROGRESS_CHANNEL = "task:subagent:progress";
33
33
 
34
+ /** EventBus channel for subagent lifecycle (start/end) */
35
+ export const TASK_SUBAGENT_LIFECYCLE_CHANNEL = "task:subagent:lifecycle";
36
+
37
+ /** Payload emitted on TASK_SUBAGENT_PROGRESS_CHANNEL */
38
+ export interface SubagentProgressPayload {
39
+ index: number;
40
+ agent: string;
41
+ agentSource: AgentSource;
42
+ task: string;
43
+ assignment?: string;
44
+ progress: AgentProgress;
45
+ sessionFile?: string;
46
+ }
47
+
48
+ /** Payload emitted on TASK_SUBAGENT_LIFECYCLE_CHANNEL */
49
+ export interface SubagentLifecyclePayload {
50
+ id: string;
51
+ agent: string;
52
+ agentSource: AgentSource;
53
+ description?: string;
54
+ status: "started" | "completed" | "failed" | "aborted";
55
+ sessionFile?: string;
56
+ index: number;
57
+ }
58
+
34
59
  /** Single task item for parallel execution */
35
60
  export const taskItemSchema = Type.Object({
36
61
  id: Type.String({
@@ -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
6
  import { 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 });
@@ -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
  }
@@ -46,6 +46,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
46
46
  readonly description: string;
47
47
  readonly parameters = exitPlanModeSchema;
48
48
  readonly strict = true;
49
+ readonly concurrency = "exclusive";
49
50
 
50
51
  constructor(private readonly session: ToolSession) {
51
52
  this.description = renderPromptTemplate(exitPlanModeDescription);