@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.
- package/CHANGELOG.md +50 -0
- package/package.json +7 -11
- package/src/autoresearch/git.ts +25 -30
- package/src/autoresearch/tools/log-experiment.ts +61 -74
- package/src/commit/agentic/agent.ts +0 -3
- package/src/commit/agentic/index.ts +19 -22
- package/src/commit/agentic/tools/git-file-diff.ts +3 -6
- package/src/commit/agentic/tools/git-hunk.ts +3 -3
- package/src/commit/agentic/tools/git-overview.ts +6 -9
- package/src/commit/agentic/tools/index.ts +6 -8
- package/src/commit/agentic/tools/propose-commit.ts +4 -7
- package/src/commit/agentic/tools/recent-commits.ts +3 -3
- package/src/commit/agentic/tools/split-commit.ts +4 -4
- package/src/commit/changelog/index.ts +5 -9
- package/src/commit/pipeline.ts +10 -12
- package/src/config/keybindings.ts +7 -6
- package/src/config/settings-schema.ts +44 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
- package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
- package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
- package/src/index.ts +1 -0
- package/src/main.ts +24 -2
- package/src/modes/components/footer.ts +9 -29
- package/src/modes/components/hook-editor.ts +3 -3
- package/src/modes/components/hook-selector.ts +6 -1
- package/src/modes/components/session-observer-overlay.ts +472 -0
- package/src/modes/components/settings-defs.ts +19 -0
- package/src/modes/components/status-line.ts +15 -61
- package/src/modes/controllers/command-controller.ts +1 -0
- package/src/modes/controllers/event-controller.ts +59 -2
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +26 -0
- package/src/modes/interactive-mode.ts +195 -43
- package/src/modes/session-observer-registry.ts +146 -0
- package/src/modes/shared.ts +0 -42
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/keybinding-matchers.ts +9 -0
- package/src/prompts/system/custom-system-prompt.md +5 -0
- package/src/prompts/system/system-prompt.md +6 -0
- package/src/sdk.ts +28 -13
- package/src/secrets/index.ts +1 -1
- package/src/secrets/obfuscator.ts +24 -16
- package/src/session/agent-session.ts +75 -30
- package/src/session/session-manager.ts +15 -5
- package/src/system-prompt.ts +4 -0
- package/src/task/executor.ts +28 -0
- package/src/task/index.ts +88 -78
- package/src/task/types.ts +25 -0
- package/src/task/worktree.ts +127 -145
- package/src/tools/exit-plan-mode.ts +1 -0
- package/src/tools/gh.ts +120 -297
- package/src/tools/read.ts +13 -79
- package/src/utils/external-editor.ts +11 -5
- package/src/utils/git.ts +1400 -0
- package/src/web/search/render.ts +6 -4
- package/src/commit/git/errors.ts +0 -9
- package/src/commit/git/index.ts +0 -210
- package/src/commit/git/operations.ts +0 -54
- 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({
|
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
55
|
+
await git.worktree.tryRemove(repoRoot, worktreeDir);
|
|
58
56
|
await fs.rm(worktreeDir, { recursive: true, force: true });
|
|
59
|
-
await
|
|
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
|
|
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
|
|
113
|
-
const staged = await
|
|
114
|
-
const unstaged = await
|
|
115
|
-
const
|
|
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
|
|
161
|
-
await
|
|
162
|
-
await
|
|
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
|
-
|
|
197
|
-
await
|
|
198
|
-
|
|
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
|
|
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
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
254
|
-
const unstaged = await
|
|
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
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
|
309
|
+
await git.patch.applyText(nestedDir, patch);
|
|
359
310
|
}
|
|
360
311
|
|
|
361
312
|
// Commit so nested repo history reflects the task changes
|
|
362
|
-
|
|
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
|
|
368
|
-
await
|
|
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
|
|
376
|
-
const commonDir =
|
|
377
|
-
if (commonDir) {
|
|
378
|
-
const
|
|
379
|
-
|
|
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
|
|
468
|
+
await git.branch.create(repoRoot, branchName);
|
|
522
469
|
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
|
|
523
470
|
try {
|
|
524
|
-
await
|
|
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
|
|
528
|
-
|
|
529
|
-
if (
|
|
530
|
-
const stderr =
|
|
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:
|
|
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
|
-
|
|
541
|
-
await fs.rm(patchPath, { force: true });
|
|
486
|
+
throw err;
|
|
542
487
|
}
|
|
543
|
-
await
|
|
488
|
+
await git.stage.files(tmpDir);
|
|
544
489
|
const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
|
|
545
|
-
await
|
|
490
|
+
await git.commit(tmpDir, msg);
|
|
546
491
|
} finally {
|
|
547
|
-
await
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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
|
|
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);
|