chainlesschain 0.45.11 → 0.45.19

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 (81) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/AppLayout-B00RARl2.js +1 -0
  3. package/src/assets/web-panel/assets/AppLayout-CFP4dGIJ.css +1 -0
  4. package/src/assets/web-panel/assets/{Chat-5f__rMCR.js → Chat-DXtvKoM0.js} +1 -1
  5. package/src/assets/web-panel/assets/{Cron-C4mrNC4c.js → Cron-BJ4ODHOy.js} +1 -1
  6. package/src/assets/web-panel/assets/Dashboard-3iIpp3zd.js +3 -0
  7. package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +1 -0
  8. package/src/assets/web-panel/assets/{Logs-CC_Zuh66.js → Logs-CSeKZEG_.js} +1 -1
  9. package/src/assets/web-panel/assets/{McpTools-B15GiN3u.js → McpTools-BYQAK11r.js} +2 -2
  10. package/src/assets/web-panel/assets/{Memory-Dbd7oLOH.js → Memory-gkUAPyuZ.js} +2 -2
  11. package/src/assets/web-panel/assets/{Notes-CEkc49fY.js → Notes-bjNrQgAo.js} +1 -1
  12. package/src/assets/web-panel/assets/{Providers-CjyPHW00.js → Providers-Dbf57Tbv.js} +1 -1
  13. package/src/assets/web-panel/assets/{Services-XFzHMRRd.js → Services-CS0oMdxh.js} +1 -1
  14. package/src/assets/web-panel/assets/{Skills-D8oxmB3U.js → Skills-B2fgruv8.js} +1 -1
  15. package/src/assets/web-panel/assets/Tasks-BJjN_YEm.css +1 -0
  16. package/src/assets/web-panel/assets/Tasks-qULws8pc.js +1 -0
  17. package/src/assets/web-panel/assets/{antd-ChLPLhSn.js → antd-CJSBocer.js} +1 -1
  18. package/src/assets/web-panel/assets/chat-DnH09sSR.js +1 -0
  19. package/src/assets/web-panel/assets/{index-DQ5xXK7O.js → index-CF2CqPYX.js} +2 -2
  20. package/src/assets/web-panel/assets/{markdown-DtbPhnFe.js → markdown-Bo5cVN4u.js} +1 -1
  21. package/src/assets/web-panel/assets/ws-DjelKkD6.js +1 -0
  22. package/src/assets/web-panel/index.html +2 -2
  23. package/src/commands/agent.js +7 -8
  24. package/src/commands/chat.js +9 -11
  25. package/src/commands/serve.js +11 -106
  26. package/src/commands/session.js +185 -18
  27. package/src/commands/ui.js +10 -151
  28. package/src/gateways/repl/agent-repl.js +1 -0
  29. package/src/gateways/repl/chat-repl.js +1 -0
  30. package/src/gateways/ui/web-ui-server.js +1 -0
  31. package/src/gateways/ws/action-protocol.js +83 -0
  32. package/src/gateways/ws/message-dispatcher.js +73 -0
  33. package/src/gateways/ws/session-protocol.js +396 -0
  34. package/src/gateways/ws/task-protocol.js +55 -0
  35. package/src/gateways/ws/worktree-protocol.js +315 -0
  36. package/src/gateways/ws/ws-server.js +4 -0
  37. package/src/gateways/ws/ws-session-gateway.js +1 -0
  38. package/src/harness/background-task-manager.js +506 -0
  39. package/src/harness/background-task-worker.js +48 -0
  40. package/src/harness/compression-telemetry.js +214 -0
  41. package/src/harness/feature-flags.js +157 -0
  42. package/src/harness/jsonl-session-store.js +452 -0
  43. package/src/harness/prompt-compressor.js +416 -0
  44. package/src/harness/worktree-isolator.js +845 -0
  45. package/src/lib/agent-core.js +246 -45
  46. package/src/lib/background-task-manager.js +1 -305
  47. package/src/lib/background-task-worker.js +1 -50
  48. package/src/lib/compression-telemetry.js +5 -0
  49. package/src/lib/feature-flags.js +7 -182
  50. package/src/lib/interaction-adapter.js +32 -6
  51. package/src/lib/jsonl-session-store.js +21 -237
  52. package/src/lib/prompt-compressor.js +10 -351
  53. package/src/lib/sub-agent-context.js +91 -0
  54. package/src/lib/worktree-isolator.js +13 -231
  55. package/src/lib/ws-agent-handler.js +1 -0
  56. package/src/lib/ws-server.js +155 -359
  57. package/src/lib/ws-session-manager.js +82 -1
  58. package/src/repl/agent-repl.js +114 -32
  59. package/src/runtime/agent-runtime.js +417 -0
  60. package/src/runtime/contracts/agent-turn.js +11 -0
  61. package/src/runtime/contracts/session-record.js +31 -0
  62. package/src/runtime/contracts/task-record.js +18 -0
  63. package/src/runtime/contracts/telemetry-record.js +23 -0
  64. package/src/runtime/contracts/worktree-record.js +14 -0
  65. package/src/runtime/index.js +13 -0
  66. package/src/runtime/policies/agent-policy.js +45 -0
  67. package/src/runtime/runtime-context.js +14 -0
  68. package/src/runtime/runtime-events.js +37 -0
  69. package/src/runtime/runtime-factory.js +50 -0
  70. package/src/tools/index.js +22 -0
  71. package/src/tools/legacy-agent-tools.js +171 -0
  72. package/src/tools/registry.js +141 -0
  73. package/src/tools/tool-context.js +28 -0
  74. package/src/tools/tool-permissions.js +28 -0
  75. package/src/tools/tool-telemetry.js +39 -0
  76. package/src/assets/web-panel/assets/AppLayout-19ZC8w11.js +0 -1
  77. package/src/assets/web-panel/assets/AppLayout-CjgO-ML6.css +0 -1
  78. package/src/assets/web-panel/assets/Dashboard-CRFnDUFh.css +0 -1
  79. package/src/assets/web-panel/assets/Dashboard-DsjXpZor.js +0 -3
  80. package/src/assets/web-panel/assets/chat-C_hu-qNs.js +0 -1
  81. package/src/assets/web-panel/assets/ws-DwluTqT5.js +0 -1
@@ -0,0 +1,845 @@
1
+ /**
2
+ * Worktree Isolator - git worktree-based task isolation.
3
+ *
4
+ * Creates temporary git worktrees for parallel agent tasks,
5
+ * ensuring file operations don't interfere with the main working tree.
6
+ *
7
+ * Feature-flag gated: WORKTREE_ISOLATION
8
+ */
9
+
10
+ import { execSync } from "node:child_process";
11
+ import { existsSync, rmSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { isGitRepo, gitExec } from "../lib/git-integration.js";
14
+
15
+ const WORKTREE_DIR = ".worktrees";
16
+
17
+ export function createWorktree(repoDir, branchName, baseBranch) {
18
+ if (!isGitRepo(repoDir)) {
19
+ throw new Error("Not a git repository");
20
+ }
21
+
22
+ const worktreePath = resolve(
23
+ repoDir,
24
+ WORKTREE_DIR,
25
+ branchName.replace(/\//g, "-"),
26
+ );
27
+
28
+ if (existsSync(worktreePath)) {
29
+ throw new Error(`Worktree already exists: ${worktreePath}`);
30
+ }
31
+
32
+ const base = baseBranch || "HEAD";
33
+ gitExec(`worktree add "${worktreePath}" -b "${branchName}" ${base}`, repoDir);
34
+
35
+ return { path: worktreePath, branch: branchName };
36
+ }
37
+
38
+ export function removeWorktree(repoDir, worktreePath, options = {}) {
39
+ const deleteBranch = options.deleteBranch !== false;
40
+
41
+ let branch = null;
42
+ if (deleteBranch) {
43
+ try {
44
+ branch = execSync("git rev-parse --abbrev-ref HEAD", {
45
+ cwd: worktreePath,
46
+ encoding: "utf-8",
47
+ }).trim();
48
+ } catch (_e) {
49
+ // Can't determine branch, skip branch deletion.
50
+ }
51
+ }
52
+
53
+ try {
54
+ gitExec(`worktree remove "${worktreePath}" --force`, repoDir);
55
+ } catch (_e) {
56
+ if (existsSync(worktreePath)) {
57
+ rmSync(worktreePath, { recursive: true, force: true });
58
+ }
59
+ gitExec("worktree prune", repoDir);
60
+ }
61
+
62
+ if (
63
+ deleteBranch &&
64
+ branch &&
65
+ branch !== "HEAD" &&
66
+ !branch.startsWith("main") &&
67
+ !branch.startsWith("master")
68
+ ) {
69
+ try {
70
+ gitExec(`branch -D "${branch}"`, repoDir);
71
+ } catch (_e) {
72
+ // Branch might already be deleted.
73
+ }
74
+ }
75
+ }
76
+
77
+ export function listWorktrees(repoDir) {
78
+ if (!isGitRepo(repoDir)) return [];
79
+
80
+ try {
81
+ const output = gitExec("worktree list --porcelain", repoDir);
82
+ const worktrees = [];
83
+ let current = {};
84
+
85
+ for (const line of output.split("\n")) {
86
+ if (line.startsWith("worktree ")) {
87
+ if (current.path) worktrees.push(current);
88
+ current = { path: line.slice(9) };
89
+ } else if (line.startsWith("HEAD ")) {
90
+ current.head = line.slice(5);
91
+ } else if (line.startsWith("branch ")) {
92
+ current.branch = line.slice(7).replace("refs/heads/", "");
93
+ } else if (line === "bare") {
94
+ current.bare = true;
95
+ }
96
+ }
97
+ if (current.path) worktrees.push(current);
98
+
99
+ return worktrees;
100
+ } catch (_e) {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ export function pruneWorktrees(repoDir) {
106
+ if (!isGitRepo(repoDir)) return 0;
107
+
108
+ const before = listWorktrees(repoDir).length;
109
+ gitExec("worktree prune", repoDir);
110
+ const after = listWorktrees(repoDir).length;
111
+ return before - after;
112
+ }
113
+
114
+ export async function isolateTask(repoDir, taskId, fn) {
115
+ const branchName = `agent/${taskId}`;
116
+ const { path: worktreePath } = createWorktree(repoDir, branchName);
117
+
118
+ try {
119
+ const result = await fn(worktreePath);
120
+ const hasChanges = _hasUncommittedChanges(worktreePath);
121
+
122
+ return {
123
+ result,
124
+ branch: branchName,
125
+ worktreePath,
126
+ hasChanges,
127
+ };
128
+ } finally {
129
+ try {
130
+ const hasCommits = _hasBranchCommits(repoDir, branchName);
131
+ removeWorktree(repoDir, worktreePath, {
132
+ deleteBranch: !hasCommits,
133
+ });
134
+ } catch (_e) {
135
+ // Best-effort cleanup.
136
+ }
137
+ }
138
+ }
139
+
140
+ export function cleanupAgentWorktrees(repoDir) {
141
+ const worktrees = listWorktrees(repoDir);
142
+ let cleaned = 0;
143
+
144
+ for (const wt of worktrees) {
145
+ if (wt.branch && wt.branch.startsWith("agent/")) {
146
+ try {
147
+ removeWorktree(repoDir, wt.path, { deleteBranch: true });
148
+ cleaned++;
149
+ } catch (_e) {
150
+ // Skip if cleanup fails.
151
+ }
152
+ }
153
+ }
154
+
155
+ pruneWorktrees(repoDir);
156
+ return cleaned;
157
+ }
158
+
159
+ export function diffWorktree(repoDir, branchName, options = {}) {
160
+ const base = options.baseBranch || "HEAD";
161
+ const filePath = options.filePath || null;
162
+ const fileArg = filePath ? ` -- "${filePath}"` : "";
163
+ const statOutput = gitExec(
164
+ `diff ${base}...${branchName} --stat --numstat${fileArg}`,
165
+ repoDir,
166
+ );
167
+
168
+ const files = [];
169
+ for (const line of statOutput.split("\n")) {
170
+ const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
171
+ if (match) {
172
+ files.push({
173
+ path: match[3].trim(),
174
+ insertions: match[1] === "-" ? 0 : parseInt(match[1], 10),
175
+ deletions: match[2] === "-" ? 0 : parseInt(match[2], 10),
176
+ status: _fileStatus(repoDir, base, branchName, match[3].trim()),
177
+ });
178
+ }
179
+ }
180
+
181
+ const totalInsertions = files.reduce((sum, file) => sum + file.insertions, 0);
182
+ const totalDeletions = files.reduce((sum, file) => sum + file.deletions, 0);
183
+
184
+ let diff = "";
185
+ try {
186
+ diff = gitExec(`diff ${base}...${branchName}${fileArg}`, repoDir);
187
+ if (diff.length > 50000) {
188
+ diff = `${diff.slice(0, 50000)}\n... [diff truncated at 50KB]`;
189
+ }
190
+ } catch (_e) {
191
+ diff = "(unable to generate diff)";
192
+ }
193
+
194
+ return {
195
+ files,
196
+ summary: {
197
+ filesChanged: files.length,
198
+ insertions: totalInsertions,
199
+ deletions: totalDeletions,
200
+ },
201
+ diff,
202
+ filePath,
203
+ };
204
+ }
205
+
206
+ export function mergeWorktree(repoDir, branchName, options = {}) {
207
+ const strategy = options.strategy || "merge";
208
+ const deleteBranch = options.deleteBranch !== false;
209
+
210
+ try {
211
+ if (strategy === "squash") {
212
+ gitExec(`merge --squash ${branchName}`, repoDir);
213
+ const msg =
214
+ options.message ||
215
+ `feat: merge agent work from ${branchName} (squashed)`;
216
+ gitExec(`commit -m "${msg.replace(/"/g, '\\"')}"`, repoDir);
217
+ } else {
218
+ const msg =
219
+ options.message || `Merge branch '${branchName}' (agent task)`;
220
+ gitExec(
221
+ `merge ${branchName} --no-edit -m "${msg.replace(/"/g, '\\"')}"`,
222
+ repoDir,
223
+ );
224
+ }
225
+
226
+ if (deleteBranch) {
227
+ try {
228
+ gitExec(`branch -D "${branchName}"`, repoDir);
229
+ } catch (_e) {
230
+ // Non-critical.
231
+ }
232
+ }
233
+
234
+ return {
235
+ success: true,
236
+ strategy,
237
+ message: `Successfully merged ${branchName} via ${strategy}`,
238
+ };
239
+ } catch (err) {
240
+ const errMsg = err.message || String(err);
241
+ if (errMsg.includes("CONFLICT") || errMsg.includes("conflict")) {
242
+ const conflictPaths = _collectConflictFiles(repoDir);
243
+ if (conflictPaths.length === 0) {
244
+ conflictPaths.push(..._extractConflictPathsFromMessage(errMsg));
245
+ }
246
+ const conflicts = [...new Set(conflictPaths)].map((filePath) =>
247
+ _buildConflictSummary(repoDir, filePath, branchName),
248
+ );
249
+ const suggestions = _buildConflictSuggestions(conflicts);
250
+
251
+ try {
252
+ gitExec("merge --abort", repoDir);
253
+ } catch (_e) {
254
+ // Best effort.
255
+ }
256
+
257
+ return {
258
+ success: false,
259
+ strategy,
260
+ message: "Merge conflicts detected - manual resolution required",
261
+ conflicts,
262
+ summary: {
263
+ conflictedFiles: conflicts.length,
264
+ bothModified: conflicts.filter(
265
+ (item) => item.type === "both_modified",
266
+ ).length,
267
+ bothAdded: conflicts.filter((item) => item.type === "both_added")
268
+ .length,
269
+ deleteModify: conflicts.filter(
270
+ (item) =>
271
+ item.type === "deleted_by_us" || item.type === "deleted_by_them",
272
+ ).length,
273
+ },
274
+ suggestions,
275
+ previewEntrypoints: conflicts.map((item) => item.diffPreview.route),
276
+ };
277
+ }
278
+
279
+ return {
280
+ success: false,
281
+ strategy,
282
+ message: `Merge failed: ${errMsg}`,
283
+ };
284
+ }
285
+ }
286
+
287
+ export function previewWorktreeMerge(repoDir, branchName, options = {}) {
288
+ const strategy = options.strategy || "merge";
289
+ const baseBranch = _resolveBaseBranchForMerge(repoDir, options.baseBranch);
290
+ const worktree = _findWorktreeByBranch(repoDir, branchName);
291
+ if (!worktree?.path) {
292
+ throw new Error(`Worktree not found for branch: ${branchName}`);
293
+ }
294
+
295
+ _abortMergeIfPresent(worktree.path);
296
+
297
+ let mergeStarted = false;
298
+ try {
299
+ gitExec(`merge ${baseBranch} --no-commit --no-ff`, worktree.path);
300
+ mergeStarted = true;
301
+
302
+ return {
303
+ success: true,
304
+ previewOnly: true,
305
+ strategy,
306
+ branch: branchName,
307
+ baseBranch,
308
+ message: `Merge preview is clean. ${branchName} can be merged into ${baseBranch} without conflicts.`,
309
+ summary: {
310
+ conflictedFiles: 0,
311
+ bothModified: 0,
312
+ bothAdded: 0,
313
+ deleteModify: 0,
314
+ },
315
+ conflicts: [],
316
+ suggestions: [
317
+ `No merge conflicts detected between ${branchName} and ${baseBranch}.`,
318
+ "You can proceed with the final merge when ready.",
319
+ ],
320
+ previewEntrypoints: [],
321
+ };
322
+ } catch (error) {
323
+ const errMsg = error.message || String(error);
324
+ const conflictPaths = _collectConflictFiles(worktree.path);
325
+ if (
326
+ errMsg.includes("CONFLICT") ||
327
+ errMsg.includes("conflict") ||
328
+ conflictPaths.length > 0
329
+ ) {
330
+ const normalizedPaths =
331
+ conflictPaths.length > 0
332
+ ? conflictPaths
333
+ : _extractConflictPathsFromMessage(errMsg);
334
+ const conflicts = [...new Set(normalizedPaths)].map((filePath) =>
335
+ _buildConflictSummary(worktree.path, filePath, branchName),
336
+ );
337
+ const suggestions = _buildConflictSuggestions(conflicts);
338
+
339
+ return {
340
+ success: false,
341
+ previewOnly: true,
342
+ strategy,
343
+ branch: branchName,
344
+ baseBranch,
345
+ message: "Merge preview detected conflicts - review before merging",
346
+ conflicts,
347
+ summary: {
348
+ conflictedFiles: conflicts.length,
349
+ bothModified: conflicts.filter(
350
+ (item) => item.type === "both_modified",
351
+ ).length,
352
+ bothAdded: conflicts.filter((item) => item.type === "both_added")
353
+ .length,
354
+ deleteModify: conflicts.filter(
355
+ (item) =>
356
+ item.type === "deleted_by_us" || item.type === "deleted_by_them",
357
+ ).length,
358
+ },
359
+ suggestions,
360
+ previewEntrypoints: conflicts.map((item) => item.diffPreview.route),
361
+ };
362
+ }
363
+
364
+ throw error;
365
+ } finally {
366
+ if (mergeStarted || _collectConflictFiles(worktree.path).length > 0) {
367
+ _abortMergeIfPresent(worktree.path);
368
+ }
369
+ }
370
+ }
371
+
372
+ export function applyWorktreeAutomationCandidate(
373
+ repoDir,
374
+ branchName,
375
+ options = {},
376
+ ) {
377
+ const filePath = options.filePath || null;
378
+ const candidateId = options.candidateId || null;
379
+ const conflictType = options.conflictType || null;
380
+ const baseBranch = _resolveBaseBranchForMerge(repoDir, options.baseBranch);
381
+
382
+ if (!filePath) {
383
+ throw new Error("filePath is required");
384
+ }
385
+ if (!candidateId) {
386
+ throw new Error("candidateId is required");
387
+ }
388
+
389
+ const worktree = _findWorktreeByBranch(repoDir, branchName);
390
+ if (!worktree?.path) {
391
+ throw new Error(`Worktree not found for branch: ${branchName}`);
392
+ }
393
+
394
+ const candidate = _resolveAutomationCandidate(
395
+ conflictType,
396
+ filePath,
397
+ branchName,
398
+ candidateId,
399
+ );
400
+ if (!candidate) {
401
+ throw new Error(`Unsupported automation candidate: ${candidateId}`);
402
+ }
403
+ if (candidate.executable !== true) {
404
+ throw new Error(`Automation candidate is advisory only: ${candidateId}`);
405
+ }
406
+
407
+ _abortMergeIfPresent(worktree.path);
408
+
409
+ let mergeStarted = false;
410
+ try {
411
+ gitExec(`merge ${baseBranch} --no-commit --no-ff`, worktree.path);
412
+ mergeStarted = true;
413
+ } catch (error) {
414
+ const errMsg = error.message || String(error);
415
+ const conflictFiles = _collectConflictFiles(worktree.path);
416
+ if (
417
+ !errMsg.includes("CONFLICT") &&
418
+ !errMsg.includes("conflict") &&
419
+ conflictFiles.length === 0
420
+ ) {
421
+ _abortMergeIfPresent(worktree.path);
422
+ throw error;
423
+ }
424
+ mergeStarted = true;
425
+ }
426
+
427
+ try {
428
+ const unresolvedFiles = _collectConflictFiles(worktree.path);
429
+ if (unresolvedFiles.length > 0) {
430
+ _applyAutomationResolution(worktree.path, candidateId, filePath);
431
+
432
+ const remainingFiles = _collectConflictFiles(worktree.path);
433
+ if (remainingFiles.includes(filePath)) {
434
+ throw new Error(
435
+ `Unable to resolve ${filePath} with automation candidate ${candidateId}`,
436
+ );
437
+ }
438
+ if (remainingFiles.length > 0) {
439
+ throw new Error(
440
+ `Automation candidate ${candidateId} only resolved ${filePath}. Remaining conflicted files: ${remainingFiles.join(", ")}`,
441
+ );
442
+ }
443
+ }
444
+
445
+ const commitMessage =
446
+ options.commitMessage ||
447
+ `Resolve ${filePath} via ${candidate.label || candidateId}`;
448
+ gitExec(`commit -m "${commitMessage.replace(/"/g, '\\"')}"`, worktree.path);
449
+
450
+ const diff = diffWorktree(repoDir, branchName, { baseBranch });
451
+ const filesChanged = diff.summary?.filesChanged || 0;
452
+
453
+ return {
454
+ success: true,
455
+ branch: branchName,
456
+ baseBranch,
457
+ filePath,
458
+ candidateId,
459
+ message:
460
+ filesChanged > 0
461
+ ? `${candidate.label || candidateId} applied in ${branchName}. Review the updated branch diff before merging.`
462
+ : `${candidate.label || candidateId} applied in ${branchName}. The branch is now aligned with ${baseBranch} for ${filePath}.`,
463
+ files: diff.files,
464
+ summary: diff.summary,
465
+ diff: diff.diff,
466
+ };
467
+ } catch (error) {
468
+ if (mergeStarted) {
469
+ _abortMergeIfPresent(worktree.path);
470
+ }
471
+ throw error;
472
+ }
473
+ }
474
+
475
+ export function worktreeLog(repoDir, branchName, baseBranch = "HEAD") {
476
+ try {
477
+ const output = gitExec(
478
+ `log ${baseBranch}..${branchName} --pretty=format:"%h|%s|%ci"`,
479
+ repoDir,
480
+ );
481
+ if (!output.trim()) return [];
482
+
483
+ return output
484
+ .trim()
485
+ .split("\n")
486
+ .map((line) => {
487
+ const parts = line.replace(/^"|"$/g, "").split("|");
488
+ return {
489
+ hash: parts[0],
490
+ message: parts[1] || "",
491
+ date: parts[2] || "",
492
+ };
493
+ });
494
+ } catch (_e) {
495
+ return [];
496
+ }
497
+ }
498
+
499
+ function _fileStatus(repoDir, base, branch, filePath) {
500
+ try {
501
+ const output = gitExec(
502
+ `diff ${base}...${branch} --name-status -- "${filePath}"`,
503
+ repoDir,
504
+ );
505
+ const status = output.trim().charAt(0);
506
+ switch (status) {
507
+ case "A":
508
+ return "added";
509
+ case "D":
510
+ return "deleted";
511
+ case "M":
512
+ return "modified";
513
+ case "R":
514
+ return "renamed";
515
+ default:
516
+ return "modified";
517
+ }
518
+ } catch (_e) {
519
+ return "modified";
520
+ }
521
+ }
522
+
523
+ function _hasUncommittedChanges(worktreePath) {
524
+ try {
525
+ const output = execSync("git status --porcelain", {
526
+ cwd: worktreePath,
527
+ encoding: "utf-8",
528
+ });
529
+ return output.trim().length > 0;
530
+ } catch (_e) {
531
+ return false;
532
+ }
533
+ }
534
+
535
+ function _hasBranchCommits(repoDir, branchName) {
536
+ try {
537
+ const output = gitExec(`log HEAD..${branchName} --oneline`, repoDir);
538
+ return output.trim().length > 0;
539
+ } catch (_e) {
540
+ return false;
541
+ }
542
+ }
543
+
544
+ function _collectConflictFiles(repoDir) {
545
+ try {
546
+ const output = gitExec("diff --name-only --diff-filter=U", repoDir);
547
+ return output
548
+ .split("\n")
549
+ .map((line) => line.trim())
550
+ .filter(Boolean);
551
+ } catch (_e) {
552
+ return [];
553
+ }
554
+ }
555
+
556
+ function _extractConflictPathsFromMessage(errMsg) {
557
+ const paths = [];
558
+ const matches = String(errMsg).matchAll(
559
+ /(?:Merge conflict in|CONFLICT [^:]+:\s*)([^\n]+)/g,
560
+ );
561
+ for (const match of matches) {
562
+ const value = match[1]?.trim();
563
+ if (!value) continue;
564
+ const normalized = value.replace(/^in\s+/, "").trim();
565
+ paths.push(normalized);
566
+ }
567
+ return paths;
568
+ }
569
+
570
+ function _buildConflictSummary(repoDir, filePath, branchName) {
571
+ const statusCode = _conflictStatusCode(repoDir, filePath);
572
+ const type = _mapConflictType(statusCode);
573
+
574
+ return {
575
+ path: filePath,
576
+ statusCode,
577
+ type,
578
+ suggestion: _suggestAction(type, filePath),
579
+ automationCandidates: _buildAutomationCandidates(
580
+ type,
581
+ filePath,
582
+ branchName,
583
+ ),
584
+ diffPreview: _buildDiffPreview(repoDir, filePath, branchName),
585
+ };
586
+ }
587
+
588
+ function _conflictStatusCode(repoDir, filePath) {
589
+ try {
590
+ const output = gitExec(`status --porcelain -- "${filePath}"`, repoDir);
591
+ const firstLine = output.split("\n").find((line) => line.trim());
592
+ return firstLine ? firstLine.slice(0, 2) : "UU";
593
+ } catch (_e) {
594
+ return "UU";
595
+ }
596
+ }
597
+
598
+ function _mapConflictType(statusCode) {
599
+ switch (statusCode) {
600
+ case "UU":
601
+ return "both_modified";
602
+ case "AA":
603
+ return "both_added";
604
+ case "DU":
605
+ return "deleted_by_us";
606
+ case "UD":
607
+ return "deleted_by_them";
608
+ case "AU":
609
+ return "added_by_us";
610
+ case "UA":
611
+ return "added_by_them";
612
+ default:
613
+ return "unmerged";
614
+ }
615
+ }
616
+
617
+ function _suggestAction(type, filePath) {
618
+ switch (type) {
619
+ case "both_modified":
620
+ return `Review both sides in ${filePath} and resolve conflict markers manually.`;
621
+ case "both_added":
622
+ return `Compare the two added versions of ${filePath} and keep one or merge their contents.`;
623
+ case "deleted_by_us":
624
+ return `Decide whether ${filePath} should stay deleted locally or be restored from the agent branch.`;
625
+ case "deleted_by_them":
626
+ return `Decide whether ${filePath} should stay deleted in the agent branch or be kept from the current branch.`;
627
+ case "added_by_us":
628
+ case "added_by_them":
629
+ return `Review the newly added ${filePath} and confirm whether it should coexist or replace the other side.`;
630
+ default:
631
+ return `Inspect ${filePath} and resolve the unmerged state before retrying the merge.`;
632
+ }
633
+ }
634
+
635
+ function _buildConflictSuggestions(conflicts) {
636
+ if (conflicts.length === 0) {
637
+ return ["Run git status to inspect the merge state before retrying."];
638
+ }
639
+
640
+ const suggestions = [
641
+ `Resolve ${conflicts.length} conflicted file(s), then rerun the merge.`,
642
+ ];
643
+
644
+ if (conflicts.some((item) => item.type === "both_modified")) {
645
+ suggestions.push(
646
+ "Start with files marked both_modified; they usually need direct hunk reconciliation.",
647
+ );
648
+ }
649
+ if (
650
+ conflicts.some(
651
+ (item) =>
652
+ item.type === "deleted_by_us" || item.type === "deleted_by_them",
653
+ )
654
+ ) {
655
+ suggestions.push(
656
+ "For delete/modify conflicts, decide whether the file should exist at all before resolving content.",
657
+ );
658
+ }
659
+
660
+ return suggestions;
661
+ }
662
+
663
+ function _buildAutomationCandidates(type, filePath, branchName) {
664
+ const common = [
665
+ {
666
+ id: "preview-diff",
667
+ label: "Preview diff",
668
+ confidence: "high",
669
+ executable: false,
670
+ command: `git diff HEAD...${branchName} -- "${filePath}"`,
671
+ description: `Inspect the branch delta for ${filePath} before resolving.`,
672
+ },
673
+ ];
674
+
675
+ switch (type) {
676
+ case "both_modified":
677
+ return [
678
+ {
679
+ id: "accept-current",
680
+ label: "Keep current branch",
681
+ confidence: "medium",
682
+ executable: true,
683
+ command: `git checkout --ours -- "${filePath}" && git add "${filePath}"`,
684
+ description: `Resolve ${filePath} by keeping the current branch version.`,
685
+ },
686
+ {
687
+ id: "accept-incoming",
688
+ label: "Keep agent branch",
689
+ confidence: "medium",
690
+ executable: true,
691
+ command: `git checkout --theirs -- "${filePath}" && git add "${filePath}"`,
692
+ description: `Resolve ${filePath} by taking the incoming agent version.`,
693
+ },
694
+ ...common,
695
+ ];
696
+ case "both_added":
697
+ return [
698
+ {
699
+ id: "rename-one-side",
700
+ label: "Rename one copy",
701
+ confidence: "low",
702
+ executable: false,
703
+ command: `git checkout --theirs -- "${filePath}"`,
704
+ description: `Restore the incoming version, then rename or merge it manually to keep both copies.`,
705
+ },
706
+ ...common,
707
+ ];
708
+ case "deleted_by_us":
709
+ return [
710
+ {
711
+ id: "restore-incoming",
712
+ label: "Restore file",
713
+ confidence: "medium",
714
+ executable: true,
715
+ command: `git checkout --theirs -- "${filePath}" && git add "${filePath}"`,
716
+ description: `Bring back ${filePath} from the agent branch.`,
717
+ },
718
+ {
719
+ id: "confirm-delete",
720
+ label: "Keep deletion",
721
+ confidence: "medium",
722
+ executable: true,
723
+ command: `git rm -- "${filePath}"`,
724
+ description: `Resolve by keeping the deletion on the current branch.`,
725
+ },
726
+ ...common,
727
+ ];
728
+ case "deleted_by_them":
729
+ return [
730
+ {
731
+ id: "restore-current",
732
+ label: "Keep current file",
733
+ confidence: "medium",
734
+ executable: true,
735
+ command: `git checkout --ours -- "${filePath}" && git add "${filePath}"`,
736
+ description: `Keep the current branch copy of ${filePath}.`,
737
+ },
738
+ {
739
+ id: "accept-delete",
740
+ label: "Accept deletion",
741
+ confidence: "medium",
742
+ executable: true,
743
+ command: `git rm -- "${filePath}"`,
744
+ description: `Resolve by accepting the deletion from the agent branch.`,
745
+ },
746
+ ...common,
747
+ ];
748
+ default:
749
+ return common;
750
+ }
751
+ }
752
+
753
+ function _buildDiffPreview(repoDir, filePath, branchName) {
754
+ const preview = {
755
+ filePath,
756
+ branch: branchName,
757
+ command: `git diff HEAD...${branchName} -- "${filePath}"`,
758
+ route: {
759
+ type: "worktree-diff",
760
+ branch: branchName,
761
+ filePath,
762
+ },
763
+ };
764
+
765
+ try {
766
+ const diff = gitExec(`diff HEAD...${branchName} -- "${filePath}"`, repoDir);
767
+ preview.snippet =
768
+ diff.length > 2000
769
+ ? `${diff.slice(0, 2000)}\n... [diff truncated]`
770
+ : diff;
771
+ } catch (_e) {
772
+ preview.snippet = "";
773
+ }
774
+
775
+ return preview;
776
+ }
777
+
778
+ function _findWorktreeByBranch(repoDir, branchName) {
779
+ return (
780
+ listWorktrees(repoDir).find((item) => item.branch === branchName) || null
781
+ );
782
+ }
783
+
784
+ function _resolveAutomationCandidate(type, filePath, branchName, candidateId) {
785
+ const typesToCheck = type
786
+ ? [type]
787
+ : ["both_modified", "both_added", "deleted_by_us", "deleted_by_them"];
788
+
789
+ for (const candidateType of typesToCheck) {
790
+ const candidate = _buildAutomationCandidates(
791
+ candidateType,
792
+ filePath,
793
+ branchName,
794
+ ).find((item) => item.id === candidateId);
795
+ if (candidate) {
796
+ return candidate;
797
+ }
798
+ }
799
+
800
+ return null;
801
+ }
802
+
803
+ function _abortMergeIfPresent(repoDir) {
804
+ try {
805
+ gitExec("merge --abort", repoDir);
806
+ } catch (_e) {
807
+ // Ignore if there is no in-progress merge.
808
+ }
809
+ }
810
+
811
+ function _resolveBaseBranchForMerge(repoDir, baseBranch) {
812
+ if (baseBranch && baseBranch !== "HEAD") {
813
+ return baseBranch;
814
+ }
815
+
816
+ try {
817
+ return gitExec("rev-parse --abbrev-ref HEAD", repoDir) || "HEAD";
818
+ } catch (_e) {
819
+ return "HEAD";
820
+ }
821
+ }
822
+
823
+ function _applyAutomationResolution(repoDir, candidateId, filePath) {
824
+ switch (candidateId) {
825
+ case "accept-current":
826
+ gitExec(`checkout --theirs -- "${filePath}"`, repoDir);
827
+ gitExec(`add "${filePath}"`, repoDir);
828
+ return;
829
+ case "accept-incoming":
830
+ case "restore-incoming":
831
+ gitExec(`checkout --ours -- "${filePath}"`, repoDir);
832
+ gitExec(`add "${filePath}"`, repoDir);
833
+ return;
834
+ case "restore-current":
835
+ gitExec(`checkout --theirs -- "${filePath}"`, repoDir);
836
+ gitExec(`add "${filePath}"`, repoDir);
837
+ return;
838
+ case "confirm-delete":
839
+ case "accept-delete":
840
+ gitExec(`rm -- "${filePath}"`, repoDir);
841
+ return;
842
+ default:
843
+ throw new Error(`Unsupported automation resolution: ${candidateId}`);
844
+ }
845
+ }