@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sdk-conflict-resolver.mjs — Launch a proper SDK agent (Codex/Copilot) to
|
|
3
|
+
* resolve merge conflicts in a worktree with full file access.
|
|
4
|
+
*
|
|
5
|
+
* The key insight: mechanical `git checkout --theirs/--ours` can only handle
|
|
6
|
+
* lockfiles and generated files. Semantic conflicts (code logic, imports,
|
|
7
|
+
* config merges) need an agent that can READ both sides, UNDERSTAND the
|
|
8
|
+
* intent, and WRITE the correct resolution.
|
|
9
|
+
*
|
|
10
|
+
* This module:
|
|
11
|
+
* 1. Detects or sets up a merge state in the worktree
|
|
12
|
+
* 2. Gathers rich context (conflict diffs, branch purposes, file types)
|
|
13
|
+
* 3. Launches an SDK agent with the worktree as cwd
|
|
14
|
+
* 4. Verifies the resolution (no markers, clean commit, successful push)
|
|
15
|
+
* 5. Reports back with actionable diagnostics
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { resolve, basename } from "node:path";
|
|
20
|
+
import { writeFile, readFile, mkdir } from "node:fs/promises";
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { launchEphemeralThread } from "./agent-pool.mjs";
|
|
23
|
+
import { resolvePromptTemplate } from "./agent-prompts.mjs";
|
|
24
|
+
import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
|
|
25
|
+
import {
|
|
26
|
+
evaluateBranchSafetyForPush,
|
|
27
|
+
normalizeBaseBranch,
|
|
28
|
+
} from "./git-safety.mjs";
|
|
29
|
+
|
|
30
|
+
// ── Configuration ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const SDK_CONFLICT_TIMEOUT_MS = parseInt(
|
|
33
|
+
process.env.SDK_CONFLICT_TIMEOUT_MS || "600000",
|
|
34
|
+
10,
|
|
35
|
+
); // 10 min default
|
|
36
|
+
const SDK_CONFLICT_COOLDOWN_MS = parseInt(
|
|
37
|
+
process.env.SDK_CONFLICT_COOLDOWN_MS || "1800000",
|
|
38
|
+
10,
|
|
39
|
+
); // 30 min cooldown
|
|
40
|
+
const SDK_CONFLICT_MAX_ATTEMPTS = parseInt(
|
|
41
|
+
process.env.SDK_CONFLICT_MAX_ATTEMPTS || "4",
|
|
42
|
+
10,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// In-memory tracking (supplements the persistent orchestrator cooldowns)
|
|
46
|
+
const _sdkResolutionState = new Map();
|
|
47
|
+
|
|
48
|
+
// ── Auto-resolve strategies (same as conflict-resolver.mjs) ──────────────────
|
|
49
|
+
|
|
50
|
+
const AUTO_THEIRS = new Set([
|
|
51
|
+
"pnpm-lock.yaml",
|
|
52
|
+
"package-lock.json",
|
|
53
|
+
"yarn.lock",
|
|
54
|
+
"go.sum",
|
|
55
|
+
]);
|
|
56
|
+
const AUTO_OURS = new Set(["CHANGELOG.md", "coverage.txt", "results.txt"]);
|
|
57
|
+
const AUTO_LOCK_EXTENSIONS = [".lock"];
|
|
58
|
+
|
|
59
|
+
function classifyFile(filePath) {
|
|
60
|
+
const name = basename(filePath);
|
|
61
|
+
if (AUTO_THEIRS.has(name)) return "theirs";
|
|
62
|
+
if (AUTO_OURS.has(name)) return "ours";
|
|
63
|
+
if (AUTO_LOCK_EXTENSIONS.some((ext) => name.endsWith(ext))) return "theirs";
|
|
64
|
+
return "manual"; // needs SDK agent
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Git helpers ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function gitExec(args, cwd, timeoutMs = 30_000) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const child = spawn("git", args, {
|
|
72
|
+
cwd,
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
shell: false,
|
|
75
|
+
timeout: timeoutMs,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let stdout = "";
|
|
79
|
+
let stderr = "";
|
|
80
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
81
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
82
|
+
|
|
83
|
+
child.on("error", (err) =>
|
|
84
|
+
resolve({ success: false, stdout, stderr: err.message }),
|
|
85
|
+
);
|
|
86
|
+
child.on("exit", (code) =>
|
|
87
|
+
resolve({
|
|
88
|
+
success: code === 0,
|
|
89
|
+
stdout: stdout.trim(),
|
|
90
|
+
stderr: stderr.trim(),
|
|
91
|
+
code,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function pushBranchSafely(worktreePath, branch, baseBranch) {
|
|
98
|
+
const safety = evaluateBranchSafetyForPush(worktreePath, {
|
|
99
|
+
baseBranch,
|
|
100
|
+
remote: "origin",
|
|
101
|
+
});
|
|
102
|
+
if (!safety.safe) {
|
|
103
|
+
return {
|
|
104
|
+
success: false,
|
|
105
|
+
stderr: safety.reason || "safety check failed",
|
|
106
|
+
stdout: "",
|
|
107
|
+
code: 1,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return gitExec(["push", "origin", `HEAD:${branch}`], worktreePath, 60_000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get list of conflicted files (unmerged) in the worktree.
|
|
115
|
+
*/
|
|
116
|
+
async function getConflictedFiles(worktreePath) {
|
|
117
|
+
const result = await gitExec(
|
|
118
|
+
["diff", "--name-only", "--diff-filter=U"],
|
|
119
|
+
worktreePath,
|
|
120
|
+
);
|
|
121
|
+
if (!result.success) return [];
|
|
122
|
+
return result.stdout
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((f) => f.trim())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if a merge is in progress in the worktree.
|
|
130
|
+
*/
|
|
131
|
+
async function isMergeInProgress(worktreePath) {
|
|
132
|
+
// For worktrees, .git is a file pointing to the real git dir
|
|
133
|
+
const dotGit = resolve(worktreePath, ".git");
|
|
134
|
+
let gitDir = worktreePath;
|
|
135
|
+
|
|
136
|
+
if (existsSync(dotGit)) {
|
|
137
|
+
try {
|
|
138
|
+
const content = await readFile(dotGit, "utf8");
|
|
139
|
+
const match = content.match(/gitdir:\s*(.+)/);
|
|
140
|
+
if (match) {
|
|
141
|
+
gitDir = resolve(worktreePath, match[1].trim());
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
/* fall through */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return existsSync(resolve(gitDir, "MERGE_HEAD"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the conflict diff for each file (shows both sides).
|
|
153
|
+
*/
|
|
154
|
+
async function getConflictDiffs(worktreePath, files) {
|
|
155
|
+
const diffs = {};
|
|
156
|
+
for (const file of files.slice(0, 10)) {
|
|
157
|
+
// Cap at 10 files to avoid prompt explosion
|
|
158
|
+
const result = await gitExec(["diff", "--", file], worktreePath, 15_000);
|
|
159
|
+
if (result.stdout) {
|
|
160
|
+
// Truncate very large diffs
|
|
161
|
+
diffs[file] =
|
|
162
|
+
result.stdout.length > 4000
|
|
163
|
+
? result.stdout.slice(0, 4000) + "\n... (truncated)"
|
|
164
|
+
: result.stdout;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return diffs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if any conflict markers remain in the worktree.
|
|
172
|
+
*/
|
|
173
|
+
async function hasConflictMarkers(worktreePath) {
|
|
174
|
+
const result = await gitExec(
|
|
175
|
+
["grep", "-rl", "^<<<<<<<\\|^=======\\|^>>>>>>>", "--", "."],
|
|
176
|
+
worktreePath,
|
|
177
|
+
15_000,
|
|
178
|
+
);
|
|
179
|
+
// git grep exits with 1 when no matches — that's what we want
|
|
180
|
+
return result.success;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── State tracking ───────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check if SDK resolution is on cooldown for this branch.
|
|
187
|
+
*/
|
|
188
|
+
export function isSDKResolutionOnCooldown(branchOrTaskId) {
|
|
189
|
+
const state = _sdkResolutionState.get(branchOrTaskId);
|
|
190
|
+
if (!state) return false;
|
|
191
|
+
return Date.now() - state.lastAttempt < SDK_CONFLICT_COOLDOWN_MS;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check if SDK resolution has exceeded max attempts for this branch.
|
|
196
|
+
*/
|
|
197
|
+
export function isSDKResolutionExhausted(branchOrTaskId) {
|
|
198
|
+
const state = _sdkResolutionState.get(branchOrTaskId);
|
|
199
|
+
if (!state) return false;
|
|
200
|
+
return state.attempts >= SDK_CONFLICT_MAX_ATTEMPTS;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Record an SDK resolution attempt.
|
|
205
|
+
*/
|
|
206
|
+
function recordSDKAttempt(branchOrTaskId, result) {
|
|
207
|
+
const prev = _sdkResolutionState.get(branchOrTaskId) || {
|
|
208
|
+
attempts: 0,
|
|
209
|
+
successes: 0,
|
|
210
|
+
failures: 0,
|
|
211
|
+
};
|
|
212
|
+
_sdkResolutionState.set(branchOrTaskId, {
|
|
213
|
+
...prev,
|
|
214
|
+
attempts: prev.attempts + 1,
|
|
215
|
+
successes: prev.successes + (result ? 1 : 0),
|
|
216
|
+
failures: prev.failures + (result ? 0 : 1),
|
|
217
|
+
lastAttempt: Date.now(),
|
|
218
|
+
lastResult: result ? "success" : "failure",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Clear SDK resolution state (e.g., after successful merge).
|
|
224
|
+
*/
|
|
225
|
+
export function clearSDKResolutionState(branchOrTaskId) {
|
|
226
|
+
_sdkResolutionState.delete(branchOrTaskId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get a summary of SDK resolution state for diagnostics.
|
|
231
|
+
*/
|
|
232
|
+
export function getSDKResolutionSummary() {
|
|
233
|
+
const entries = [..._sdkResolutionState.entries()].map(([key, state]) => ({
|
|
234
|
+
key,
|
|
235
|
+
...state,
|
|
236
|
+
cooldownRemaining: Math.max(
|
|
237
|
+
0,
|
|
238
|
+
SDK_CONFLICT_COOLDOWN_MS - (Date.now() - state.lastAttempt),
|
|
239
|
+
),
|
|
240
|
+
}));
|
|
241
|
+
return {
|
|
242
|
+
total: entries.length,
|
|
243
|
+
entries,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveGitDir(worktreePath) {
|
|
248
|
+
const dotGit = resolve(worktreePath, ".git");
|
|
249
|
+
if (!existsSync(dotGit)) return null;
|
|
250
|
+
try {
|
|
251
|
+
const content = readFileSync(dotGit, "utf8");
|
|
252
|
+
const match = content.match(/gitdir:\\s*(.+)/);
|
|
253
|
+
if (!match) return null;
|
|
254
|
+
return resolve(worktreePath, match[1].trim());
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Prompt builder ───────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build a rich, actionable prompt for the SDK agent to resolve merge conflicts.
|
|
264
|
+
* This is the core differentiator — instead of "git checkout --theirs", we give
|
|
265
|
+
* the agent full context to make intelligent merge decisions.
|
|
266
|
+
*/
|
|
267
|
+
export function buildSDKConflictPrompt({
|
|
268
|
+
worktreePath,
|
|
269
|
+
branch,
|
|
270
|
+
baseBranch = "main",
|
|
271
|
+
prNumber,
|
|
272
|
+
taskTitle = "",
|
|
273
|
+
taskDescription = "",
|
|
274
|
+
conflictedFiles = [],
|
|
275
|
+
conflictDiffs = {},
|
|
276
|
+
promptTemplate = "",
|
|
277
|
+
} = {}) {
|
|
278
|
+
const autoFiles = [];
|
|
279
|
+
const manualFiles = [];
|
|
280
|
+
|
|
281
|
+
for (const file of conflictedFiles) {
|
|
282
|
+
const strategy = classifyFile(file);
|
|
283
|
+
if (strategy !== "manual") {
|
|
284
|
+
autoFiles.push({ file, strategy });
|
|
285
|
+
} else {
|
|
286
|
+
manualFiles.push(file);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const lines = [
|
|
291
|
+
`# Merge Conflict Resolution`,
|
|
292
|
+
``,
|
|
293
|
+
`You are resolving merge conflicts in a git worktree.`,
|
|
294
|
+
``,
|
|
295
|
+
`## Context`,
|
|
296
|
+
`- **Working directory**: \`${worktreePath}\``,
|
|
297
|
+
`- **PR branch** (HEAD): \`${branch}\` — this is the feature branch with new work`,
|
|
298
|
+
`- **Base branch** (incoming): \`origin/${baseBranch}\` — this is the upstream branch being merged in`,
|
|
299
|
+
prNumber ? `- **PR**: #${prNumber}` : null,
|
|
300
|
+
taskTitle ? `- **Task**: ${taskTitle}` : null,
|
|
301
|
+
taskDescription
|
|
302
|
+
? `- **Description**: ${taskDescription.slice(0, 500)}`
|
|
303
|
+
: null,
|
|
304
|
+
``,
|
|
305
|
+
`## Merge State`,
|
|
306
|
+
`A \`git merge origin/${baseBranch}\` has been started but has conflicts.`,
|
|
307
|
+
`The merge is IN PROGRESS — do NOT run \`git merge\` again.`,
|
|
308
|
+
``,
|
|
309
|
+
].filter(Boolean);
|
|
310
|
+
|
|
311
|
+
let autoFilesSection = "No auto-resolvable files.";
|
|
312
|
+
// Auto-resolvable files
|
|
313
|
+
if (autoFiles.length > 0) {
|
|
314
|
+
const autoLines = [];
|
|
315
|
+
lines.push(`## Auto-Resolvable Files (handle these first)`);
|
|
316
|
+
lines.push(`These files can be resolved mechanically. Run these commands:`);
|
|
317
|
+
lines.push("```bash");
|
|
318
|
+
lines.push(`cd "${worktreePath}"`);
|
|
319
|
+
autoLines.push(`cd "${worktreePath}"`);
|
|
320
|
+
for (const { file, strategy } of autoFiles) {
|
|
321
|
+
lines.push(
|
|
322
|
+
`git checkout --${strategy} -- "${file}" && git add "${file}"`,
|
|
323
|
+
);
|
|
324
|
+
autoLines.push(`git checkout --${strategy} -- "${file}" && git add "${file}"`);
|
|
325
|
+
}
|
|
326
|
+
lines.push("```");
|
|
327
|
+
lines.push("");
|
|
328
|
+
autoFilesSection = autoLines.join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let manualFilesSection = "No manual conflict files.";
|
|
332
|
+
// Manual files — these need intelligent resolution
|
|
333
|
+
if (manualFiles.length > 0) {
|
|
334
|
+
const manualLines = [];
|
|
335
|
+
lines.push(`## Files Requiring Intelligent Resolution`);
|
|
336
|
+
lines.push(
|
|
337
|
+
`These files have semantic conflicts that need careful merging.`,
|
|
338
|
+
);
|
|
339
|
+
lines.push(`For each file:`);
|
|
340
|
+
lines.push(
|
|
341
|
+
`1. Read the file to see the conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`)`,
|
|
342
|
+
);
|
|
343
|
+
lines.push(
|
|
344
|
+
`2. The \`<<<<<<< HEAD\` side is the **PR branch's** version (the feature work)`,
|
|
345
|
+
);
|
|
346
|
+
lines.push(
|
|
347
|
+
`3. The \`>>>>>>> origin/${baseBranch}\` side is the **base branch's** version`,
|
|
348
|
+
);
|
|
349
|
+
lines.push(
|
|
350
|
+
`4. Merge both sides intelligently — keep BOTH features when possible`,
|
|
351
|
+
);
|
|
352
|
+
lines.push(
|
|
353
|
+
`5. Remove ALL conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`)`,
|
|
354
|
+
);
|
|
355
|
+
lines.push(`6. Run \`git add <file>\` after resolving each file`);
|
|
356
|
+
lines.push(``);
|
|
357
|
+
manualLines.push(`Manual files: ${manualFiles.join(", ")}`);
|
|
358
|
+
|
|
359
|
+
for (const file of manualFiles) {
|
|
360
|
+
lines.push(`### \`${file}\``);
|
|
361
|
+
manualLines.push(`- ${file}`);
|
|
362
|
+
if (conflictDiffs[file]) {
|
|
363
|
+
lines.push("Conflict diff preview:");
|
|
364
|
+
lines.push("```diff");
|
|
365
|
+
lines.push(conflictDiffs[file]);
|
|
366
|
+
lines.push("```");
|
|
367
|
+
manualLines.push(conflictDiffs[file]);
|
|
368
|
+
} else {
|
|
369
|
+
lines.push(`Read this file to see the conflicts.`);
|
|
370
|
+
manualLines.push("(read file to inspect markers)");
|
|
371
|
+
}
|
|
372
|
+
lines.push("");
|
|
373
|
+
}
|
|
374
|
+
manualFilesSection = manualLines.join("\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Resolution instructions
|
|
378
|
+
lines.push(`## After Resolving All Files`);
|
|
379
|
+
lines.push(`1. Verify NO conflict markers remain:`);
|
|
380
|
+
lines.push(" ```bash");
|
|
381
|
+
lines.push(
|
|
382
|
+
` git grep -n "^<<<<<<<\\|^=======\\|^>>>>>>>" -- . || echo "Clean"`,
|
|
383
|
+
);
|
|
384
|
+
lines.push(" ```");
|
|
385
|
+
lines.push(`2. Commit the merge:`);
|
|
386
|
+
lines.push(" ```bash");
|
|
387
|
+
lines.push(` git commit --no-edit`);
|
|
388
|
+
lines.push(" ```");
|
|
389
|
+
lines.push(`3. Push the result:`);
|
|
390
|
+
lines.push(" ```bash");
|
|
391
|
+
lines.push(` git push origin HEAD:${branch}`);
|
|
392
|
+
lines.push(" ```");
|
|
393
|
+
lines.push(``);
|
|
394
|
+
lines.push(`## CRITICAL RULES`);
|
|
395
|
+
lines.push(
|
|
396
|
+
`- Do NOT abort the merge. Resolve the conflicts and complete it.`,
|
|
397
|
+
);
|
|
398
|
+
lines.push(`- Do NOT run \`git merge\` again — one is already in progress.`);
|
|
399
|
+
lines.push(`- Do NOT use \`git rebase\` — we use merge-based updates.`);
|
|
400
|
+
lines.push(
|
|
401
|
+
`- When in doubt about conflicting code, keep BOTH sides and deduplicate imports/declarations.`,
|
|
402
|
+
);
|
|
403
|
+
lines.push(
|
|
404
|
+
`- For import conflicts: combine both sets of imports, remove exact duplicates.`,
|
|
405
|
+
);
|
|
406
|
+
lines.push(
|
|
407
|
+
`- After resolution, verify the code parses correctly (e.g., \`node --check\` for .mjs files).`,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const fallback = lines.join("\n");
|
|
411
|
+
return resolvePromptTemplate(
|
|
412
|
+
promptTemplate,
|
|
413
|
+
{
|
|
414
|
+
WORKTREE_PATH: worktreePath || "",
|
|
415
|
+
BRANCH: branch || "",
|
|
416
|
+
BASE_BRANCH: baseBranch || "main",
|
|
417
|
+
PR_LINE: prNumber ? `- PR: #${prNumber}` : "",
|
|
418
|
+
TASK_TITLE_LINE: taskTitle ? `- Task: ${taskTitle}` : "",
|
|
419
|
+
TASK_DESCRIPTION_LINE: taskDescription
|
|
420
|
+
? `- Description: ${taskDescription.slice(0, 500)}`
|
|
421
|
+
: "",
|
|
422
|
+
AUTO_FILES_SECTION: autoFilesSection,
|
|
423
|
+
MANUAL_FILES_SECTION: manualFilesSection,
|
|
424
|
+
},
|
|
425
|
+
fallback,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Core SDK launcher ────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Launch a Codex exec process in the worktree to resolve conflicts.
|
|
433
|
+
* Uses `codex exec --full-auto` with the worktree as cwd so it has
|
|
434
|
+
* full read/write access to the conflicted files.
|
|
435
|
+
*
|
|
436
|
+
* @param {object} opts
|
|
437
|
+
* @param {string} opts.worktreePath - Path to the git worktree with conflicts
|
|
438
|
+
* @param {string} opts.branch - The PR branch name
|
|
439
|
+
* @param {string} opts.baseBranch - The base branch being merged
|
|
440
|
+
* @param {number} [opts.prNumber] - PR number
|
|
441
|
+
* @param {string} [opts.taskTitle] - Task title for context
|
|
442
|
+
* @param {string} [opts.taskDescription] - Task description for context
|
|
443
|
+
* @param {string} [opts.logDir] - Directory for resolution logs
|
|
444
|
+
* @param {number} [opts.timeoutMs] - Timeout in ms
|
|
445
|
+
* @param {string} [opts.promptTemplate] - Optional custom prompt template
|
|
446
|
+
* @returns {Promise<{success: boolean, resolvedFiles: string[], log: string, error?: string}>}
|
|
447
|
+
*/
|
|
448
|
+
export async function resolveConflictsWithSDK({
|
|
449
|
+
worktreePath,
|
|
450
|
+
branch,
|
|
451
|
+
baseBranch = "main",
|
|
452
|
+
prNumber = null,
|
|
453
|
+
taskTitle = "",
|
|
454
|
+
taskDescription = "",
|
|
455
|
+
logDir = null,
|
|
456
|
+
timeoutMs = SDK_CONFLICT_TIMEOUT_MS,
|
|
457
|
+
promptTemplate = "",
|
|
458
|
+
} = {}) {
|
|
459
|
+
const tag = `[sdk-resolve(${branch?.slice(0, 20) || "?"})]`;
|
|
460
|
+
const { branch: normalizedBaseBranch, remoteRef: normalizedBaseRef } =
|
|
461
|
+
normalizeBaseBranch(baseBranch, "origin");
|
|
462
|
+
|
|
463
|
+
// ── Guard: cooldown ─────────────────────────────────────────────
|
|
464
|
+
if (isSDKResolutionOnCooldown(branch)) {
|
|
465
|
+
const state = _sdkResolutionState.get(branch);
|
|
466
|
+
const remaining = Math.round(
|
|
467
|
+
(SDK_CONFLICT_COOLDOWN_MS - (Date.now() - state.lastAttempt)) / 1000,
|
|
468
|
+
);
|
|
469
|
+
console.log(`${tag} on cooldown (${remaining}s remaining) — skipping`);
|
|
470
|
+
return {
|
|
471
|
+
success: false,
|
|
472
|
+
resolvedFiles: [],
|
|
473
|
+
log: "",
|
|
474
|
+
error: `SDK resolution on cooldown (${remaining}s remaining)`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Guard: max attempts ─────────────────────────────────────────
|
|
479
|
+
if (isSDKResolutionExhausted(branch)) {
|
|
480
|
+
console.log(
|
|
481
|
+
`${tag} max attempts (${SDK_CONFLICT_MAX_ATTEMPTS}) reached — manual intervention needed`,
|
|
482
|
+
);
|
|
483
|
+
return {
|
|
484
|
+
success: false,
|
|
485
|
+
resolvedFiles: [],
|
|
486
|
+
log: "",
|
|
487
|
+
error: `Max SDK resolution attempts (${SDK_CONFLICT_MAX_ATTEMPTS}) exhausted`,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── Guard: worktree exists ──────────────────────────────────────
|
|
492
|
+
if (!worktreePath || !existsSync(worktreePath)) {
|
|
493
|
+
console.warn(`${tag} worktree not found: ${worktreePath}`);
|
|
494
|
+
return {
|
|
495
|
+
success: false,
|
|
496
|
+
resolvedFiles: [],
|
|
497
|
+
log: "",
|
|
498
|
+
error: `Worktree not found: ${worktreePath}`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── Step 1: Check merge state ───────────────────────────────────
|
|
503
|
+
const mergeActive = await isMergeInProgress(worktreePath);
|
|
504
|
+
let mergeAttemptError = "";
|
|
505
|
+
if (!mergeActive) {
|
|
506
|
+
console.log(
|
|
507
|
+
`${tag} no merge in progress — starting merge of ${normalizedBaseRef}`,
|
|
508
|
+
);
|
|
509
|
+
// Fetch and start the merge
|
|
510
|
+
await gitExec(["fetch", "origin", normalizedBaseBranch], worktreePath);
|
|
511
|
+
const mergeResult = await gitExec(
|
|
512
|
+
["merge", normalizedBaseRef, "--no-edit"],
|
|
513
|
+
worktreePath,
|
|
514
|
+
60_000,
|
|
515
|
+
);
|
|
516
|
+
mergeAttemptError = mergeResult.stderr || mergeResult.stdout || "";
|
|
517
|
+
if (mergeResult.success) {
|
|
518
|
+
console.log(`${tag} merge completed cleanly — no conflicts`);
|
|
519
|
+
const pushResult = await pushBranchSafely(
|
|
520
|
+
worktreePath,
|
|
521
|
+
branch,
|
|
522
|
+
normalizedBaseBranch,
|
|
523
|
+
);
|
|
524
|
+
if (!pushResult.success) {
|
|
525
|
+
recordSDKAttempt(branch, false);
|
|
526
|
+
return {
|
|
527
|
+
success: false,
|
|
528
|
+
resolvedFiles: [],
|
|
529
|
+
log: "Merge completed cleanly, but push was blocked",
|
|
530
|
+
error: pushResult.stderr || "push failed",
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
recordSDKAttempt(branch, true);
|
|
534
|
+
return {
|
|
535
|
+
success: true,
|
|
536
|
+
resolvedFiles: [],
|
|
537
|
+
log: "Merge completed cleanly",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// Merge failed due to conflicts — continue to resolution
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── Step 2: Get conflicted files ────────────────────────────────
|
|
544
|
+
const conflictedFiles = await getConflictedFiles(worktreePath);
|
|
545
|
+
if (conflictedFiles.length === 0) {
|
|
546
|
+
// If merge isn't active and there are no conflicted files, we should not
|
|
547
|
+
// blindly commit. This means merge failed before entering a merge state.
|
|
548
|
+
const mergeStillActive = await isMergeInProgress(worktreePath);
|
|
549
|
+
if (!mergeStillActive) {
|
|
550
|
+
recordSDKAttempt(branch, false);
|
|
551
|
+
return {
|
|
552
|
+
success: false,
|
|
553
|
+
resolvedFiles: [],
|
|
554
|
+
log: "",
|
|
555
|
+
error: `Merge did not enter conflict state: ${mergeAttemptError || "unknown merge error"}`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
console.log(`${tag} no conflicted files found — committing merge`);
|
|
560
|
+
const commitResult = await gitExec(["commit", "--no-edit"], worktreePath);
|
|
561
|
+
if (!commitResult.success) {
|
|
562
|
+
recordSDKAttempt(branch, false);
|
|
563
|
+
return {
|
|
564
|
+
success: false,
|
|
565
|
+
resolvedFiles: [],
|
|
566
|
+
log: "",
|
|
567
|
+
error: commitResult.stderr || "merge commit failed",
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const pushResult = await pushBranchSafely(
|
|
571
|
+
worktreePath,
|
|
572
|
+
branch,
|
|
573
|
+
normalizedBaseBranch,
|
|
574
|
+
);
|
|
575
|
+
if (!pushResult.success) {
|
|
576
|
+
recordSDKAttempt(branch, false);
|
|
577
|
+
return {
|
|
578
|
+
success: false,
|
|
579
|
+
resolvedFiles: [],
|
|
580
|
+
log: "Merge committed, but push was blocked",
|
|
581
|
+
error: pushResult.stderr || "push failed",
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
recordSDKAttempt(branch, true);
|
|
585
|
+
return { success: true, resolvedFiles: [], log: "No conflicts to resolve" };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
console.log(
|
|
589
|
+
`${tag} ${conflictedFiles.length} conflicted files: ${conflictedFiles.join(", ")}`,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// ── Step 3: Auto-resolve trivial files first ────────────────────
|
|
593
|
+
const autoResolved = [];
|
|
594
|
+
const needsSDK = [];
|
|
595
|
+
for (const file of conflictedFiles) {
|
|
596
|
+
const strategy = classifyFile(file);
|
|
597
|
+
if (strategy !== "manual") {
|
|
598
|
+
const result = await gitExec(
|
|
599
|
+
["checkout", `--${strategy}`, "--", file],
|
|
600
|
+
worktreePath,
|
|
601
|
+
);
|
|
602
|
+
if (result.success) {
|
|
603
|
+
await gitExec(["add", file], worktreePath);
|
|
604
|
+
autoResolved.push(file);
|
|
605
|
+
console.log(`${tag} auto-resolved (${strategy}): ${file}`);
|
|
606
|
+
} else {
|
|
607
|
+
needsSDK.push(file); // fallback to SDK even for "easy" files
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
needsSDK.push(file);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// If all files were auto-resolved, commit and push
|
|
615
|
+
if (needsSDK.length === 0) {
|
|
616
|
+
console.log(`${tag} all ${autoResolved.length} files auto-resolved`);
|
|
617
|
+
const commitResult = await gitExec(["commit", "--no-edit"], worktreePath);
|
|
618
|
+
if (commitResult.success) {
|
|
619
|
+
const pushResult = await pushBranchSafely(
|
|
620
|
+
worktreePath,
|
|
621
|
+
branch,
|
|
622
|
+
normalizedBaseBranch,
|
|
623
|
+
);
|
|
624
|
+
if (!pushResult.success) {
|
|
625
|
+
recordSDKAttempt(branch, false);
|
|
626
|
+
return {
|
|
627
|
+
success: false,
|
|
628
|
+
resolvedFiles: autoResolved,
|
|
629
|
+
log: `Auto-resolved ${autoResolved.length} files, but push was blocked`,
|
|
630
|
+
error: pushResult.stderr || "push failed",
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
recordSDKAttempt(branch, true);
|
|
634
|
+
return {
|
|
635
|
+
success: true,
|
|
636
|
+
resolvedFiles: autoResolved,
|
|
637
|
+
log: `Auto-resolved ${autoResolved.length} files`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── Step 4: Get conflict diffs for SDK prompt context ───────────
|
|
643
|
+
const conflictDiffs = await getConflictDiffs(worktreePath, needsSDK);
|
|
644
|
+
|
|
645
|
+
// ── Step 5: Build the rich prompt ───────────────────────────────
|
|
646
|
+
const prompt = buildSDKConflictPrompt({
|
|
647
|
+
worktreePath,
|
|
648
|
+
branch,
|
|
649
|
+
baseBranch: normalizedBaseBranch,
|
|
650
|
+
prNumber,
|
|
651
|
+
taskTitle,
|
|
652
|
+
taskDescription,
|
|
653
|
+
conflictedFiles: needsSDK,
|
|
654
|
+
conflictDiffs,
|
|
655
|
+
promptTemplate,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// ── Step 6: Launch SDK agent ────────────────────────────────────
|
|
659
|
+
console.log(
|
|
660
|
+
`${tag} launching SDK agent for ${needsSDK.length} files (timeout: ${timeoutMs / 1000}s)`,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
const sdkResult = await launchSDKAgent(prompt, worktreePath, timeoutMs);
|
|
664
|
+
|
|
665
|
+
// ── Step 7: Log the result ──────────────────────────────────────
|
|
666
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
667
|
+
const effectiveLogDir =
|
|
668
|
+
logDir ||
|
|
669
|
+
resolve(worktreePath, "..", "..", "logs") ||
|
|
670
|
+
resolve(worktreePath, "logs");
|
|
671
|
+
try {
|
|
672
|
+
await mkdir(effectiveLogDir, { recursive: true });
|
|
673
|
+
const logPath = resolve(
|
|
674
|
+
effectiveLogDir,
|
|
675
|
+
`sdk-conflict-${branch?.replace(/\//g, "_") || "unknown"}-${timestamp}.log`,
|
|
676
|
+
);
|
|
677
|
+
await writeFile(
|
|
678
|
+
logPath,
|
|
679
|
+
[
|
|
680
|
+
`SDK Conflict Resolution Log`,
|
|
681
|
+
`Branch: ${branch}`,
|
|
682
|
+
`Base: ${normalizedBaseBranch}`,
|
|
683
|
+
`PR: #${prNumber || "?"}`,
|
|
684
|
+
`Files: ${needsSDK.join(", ")}`,
|
|
685
|
+
`Auto-resolved: ${autoResolved.join(", ") || "none"}`,
|
|
686
|
+
`Timeout: ${timeoutMs}ms`,
|
|
687
|
+
`Result: ${sdkResult.success ? "SUCCESS" : "FAILURE"}`,
|
|
688
|
+
`---`,
|
|
689
|
+
sdkResult.output || sdkResult.error || "(no output)",
|
|
690
|
+
].join("\n"),
|
|
691
|
+
"utf8",
|
|
692
|
+
);
|
|
693
|
+
console.log(`${tag} log saved: ${logPath}`);
|
|
694
|
+
} catch (err) {
|
|
695
|
+
console.warn(`${tag} failed to save log: ${err.message}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── Step 8: Verify resolution ───────────────────────────────────
|
|
699
|
+
if (sdkResult.success) {
|
|
700
|
+
const markersRemain = await hasConflictMarkers(worktreePath);
|
|
701
|
+
if (markersRemain) {
|
|
702
|
+
console.warn(
|
|
703
|
+
`${tag} SDK agent exited 0 but conflict markers remain — marking as failure`,
|
|
704
|
+
);
|
|
705
|
+
recordSDKAttempt(branch, false);
|
|
706
|
+
return {
|
|
707
|
+
success: false,
|
|
708
|
+
resolvedFiles: autoResolved,
|
|
709
|
+
log: sdkResult.output,
|
|
710
|
+
error: "Conflict markers remain after SDK resolution",
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
console.log(`${tag} SDK resolution succeeded — conflict-free`);
|
|
714
|
+
recordSDKAttempt(branch, true);
|
|
715
|
+
return {
|
|
716
|
+
success: true,
|
|
717
|
+
resolvedFiles: [...autoResolved, ...needsSDK],
|
|
718
|
+
log: sdkResult.output,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
console.warn(`${tag} SDK resolution failed: ${sdkResult.error}`);
|
|
723
|
+
recordSDKAttempt(branch, false);
|
|
724
|
+
return {
|
|
725
|
+
success: false,
|
|
726
|
+
resolvedFiles: autoResolved,
|
|
727
|
+
log: sdkResult.output,
|
|
728
|
+
error: sdkResult.error,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ── SDK Agent Launcher ───────────────────────────────────────────────────────
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Launch an SDK agent with FULL ACCESS to resolve conflicts.
|
|
736
|
+
*
|
|
737
|
+
* CRITICAL: Always creates a FRESH, DEDICATED Codex SDK thread for each
|
|
738
|
+
* conflict resolution. NEVER reuses the primary agent's session or the
|
|
739
|
+
* Telegram bot's thread. This prevents:
|
|
740
|
+
* - Context contamination from ongoing workspace conversations
|
|
741
|
+
* - Collisions with active /background or Telegram agent turns
|
|
742
|
+
* - Token overflow from accumulated unrelated context
|
|
743
|
+
*
|
|
744
|
+
* Priority order:
|
|
745
|
+
* 1. Fresh Codex SDK thread — same capabilities as /background but isolated.
|
|
746
|
+
* 2. Codex CLI fallback — `codex exec` with configurable sandbox.
|
|
747
|
+
* 3. Copilot CLI fallback.
|
|
748
|
+
*/
|
|
749
|
+
async function launchSDKAgent(prompt, cwd, timeoutMs) {
|
|
750
|
+
// ── Primary: Fresh Codex SDK thread (NEVER reuse existing session) ──────
|
|
751
|
+
// Creates a brand new Thread with danger-full-access sandbox, same config
|
|
752
|
+
// as the /background command, but completely independent from the primary
|
|
753
|
+
// agent. This guarantees clean context for conflict resolution.
|
|
754
|
+
try {
|
|
755
|
+
const sdkResult = await launchEphemeralThread(prompt, cwd, timeoutMs);
|
|
756
|
+
if (sdkResult.success || sdkResult.output) {
|
|
757
|
+
return sdkResult;
|
|
758
|
+
}
|
|
759
|
+
console.warn(
|
|
760
|
+
`[sdk-resolve] fresh SDK thread returned no actionable output — trying CLI fallback`,
|
|
761
|
+
);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
console.warn(
|
|
764
|
+
`[sdk-resolve] fresh SDK thread failed: ${err.message} — trying CLI fallback`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ── Fallback: Codex CLI with configurable sandbox ───────────────────────
|
|
769
|
+
const codexAvailable = await isCommandAvailable("codex");
|
|
770
|
+
if (codexAvailable) {
|
|
771
|
+
return launchCodexExec(prompt, cwd, timeoutMs);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── Fallback: Copilot CLI ───────────────────────────────────────────────
|
|
775
|
+
const copilotAvailable = await isCommandAvailable("github-copilot-cli");
|
|
776
|
+
if (copilotAvailable) {
|
|
777
|
+
return launchCopilotExec(prompt, cwd, timeoutMs);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
success: false,
|
|
782
|
+
output: "",
|
|
783
|
+
error:
|
|
784
|
+
"No SDK agent available (fresh thread, codex CLI, and copilot CLI all failed)",
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function isCommandAvailable(cmd) {
|
|
789
|
+
return new Promise((resolve) => {
|
|
790
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
791
|
+
const child = spawn(which, [cmd], {
|
|
792
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
793
|
+
shell: false,
|
|
794
|
+
timeout: 5000,
|
|
795
|
+
});
|
|
796
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
797
|
+
child.on("error", () => resolve(false));
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function launchCodexExec(prompt, cwd, timeoutMs) {
|
|
802
|
+
return new Promise((resolvePromise) => {
|
|
803
|
+
let child;
|
|
804
|
+
try {
|
|
805
|
+
const args = [
|
|
806
|
+
"exec",
|
|
807
|
+
"--ask-for-approval",
|
|
808
|
+
"never",
|
|
809
|
+
"--sandbox",
|
|
810
|
+
process.env.CODEX_SANDBOX || "workspace-write",
|
|
811
|
+
"-C",
|
|
812
|
+
cwd,
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
const gitDir = resolveGitDir(cwd);
|
|
816
|
+
if (gitDir) {
|
|
817
|
+
args.push("--add-dir", gitDir);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Auto-detect Azure: configure Codex CLI for Azure via -c overrides,
|
|
821
|
+
// or strip OPENAI_BASE_URL so it uses ChatGPT OAuth for non-Azure.
|
|
822
|
+
const { env: codexEnv } = resolveCodexProfileRuntime(process.env);
|
|
823
|
+
const baseUrl = codexEnv.OPENAI_BASE_URL || "";
|
|
824
|
+
const isAzure = baseUrl.includes(".openai.azure.com");
|
|
825
|
+
if (isAzure) {
|
|
826
|
+
if (codexEnv.OPENAI_API_KEY && !codexEnv.AZURE_OPENAI_API_KEY) {
|
|
827
|
+
codexEnv.AZURE_OPENAI_API_KEY = codexEnv.OPENAI_API_KEY;
|
|
828
|
+
}
|
|
829
|
+
args.push(
|
|
830
|
+
"-c", 'model_provider="azure"',
|
|
831
|
+
"-c", 'model_providers.azure.name="Azure OpenAI"',
|
|
832
|
+
"-c", `model_providers.azure.base_url="${baseUrl}"`,
|
|
833
|
+
"-c", 'model_providers.azure.env_key="AZURE_OPENAI_API_KEY"',
|
|
834
|
+
"-c", 'model_providers.azure.wire_api="responses"',
|
|
835
|
+
);
|
|
836
|
+
const azureModel = codexEnv.CODEX_MODEL;
|
|
837
|
+
if (azureModel) {
|
|
838
|
+
args.push("-m", azureModel);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
delete codexEnv.OPENAI_BASE_URL;
|
|
842
|
+
|
|
843
|
+
if (process.platform === "win32") {
|
|
844
|
+
const shellQuote = (value) =>
|
|
845
|
+
/\s/.test(value) ? `"${String(value).replace(/"/g, '\\"')}"` : value;
|
|
846
|
+
const fullCommand = ["codex", ...args].map(shellQuote).join(" ");
|
|
847
|
+
child = spawn(fullCommand, {
|
|
848
|
+
cwd,
|
|
849
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
850
|
+
shell: true,
|
|
851
|
+
timeout: timeoutMs,
|
|
852
|
+
env: codexEnv,
|
|
853
|
+
});
|
|
854
|
+
} else {
|
|
855
|
+
child = spawn("codex", args, {
|
|
856
|
+
cwd,
|
|
857
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
858
|
+
shell: false,
|
|
859
|
+
timeout: timeoutMs,
|
|
860
|
+
env: codexEnv,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
} catch (err) {
|
|
864
|
+
return resolvePromise({
|
|
865
|
+
success: false,
|
|
866
|
+
output: "",
|
|
867
|
+
error: `spawn codex failed: ${err.message}`,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
child.stdin.write(prompt);
|
|
873
|
+
child.stdin.end();
|
|
874
|
+
} catch {
|
|
875
|
+
/* stdin may already be closed */
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
let stdout = "";
|
|
879
|
+
let stderr = "";
|
|
880
|
+
|
|
881
|
+
child.stdout.on("data", (chunk) => (stdout += chunk.toString()));
|
|
882
|
+
child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
|
|
883
|
+
|
|
884
|
+
const timer = setTimeout(() => {
|
|
885
|
+
try {
|
|
886
|
+
child.kill("SIGTERM");
|
|
887
|
+
} catch {
|
|
888
|
+
/* best effort */
|
|
889
|
+
}
|
|
890
|
+
resolvePromise({
|
|
891
|
+
success: false,
|
|
892
|
+
output: stdout,
|
|
893
|
+
error: `timeout after ${timeoutMs}ms`,
|
|
894
|
+
});
|
|
895
|
+
}, timeoutMs);
|
|
896
|
+
|
|
897
|
+
child.on("error", (err) => {
|
|
898
|
+
clearTimeout(timer);
|
|
899
|
+
resolvePromise({
|
|
900
|
+
success: false,
|
|
901
|
+
output: stdout,
|
|
902
|
+
error: err.message,
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
child.on("exit", (code) => {
|
|
907
|
+
clearTimeout(timer);
|
|
908
|
+
resolvePromise({
|
|
909
|
+
success: code === 0,
|
|
910
|
+
output: stdout + (stderr ? "\n" + stderr : ""),
|
|
911
|
+
error: code !== 0 ? `exit code ${code}` : null,
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function launchCopilotExec(prompt, cwd, timeoutMs) {
|
|
918
|
+
return new Promise((resolvePromise) => {
|
|
919
|
+
let child;
|
|
920
|
+
try {
|
|
921
|
+
child = spawn("github-copilot-cli", ["--prompt", prompt], {
|
|
922
|
+
cwd,
|
|
923
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
924
|
+
shell: false,
|
|
925
|
+
timeout: timeoutMs,
|
|
926
|
+
env: { ...process.env },
|
|
927
|
+
});
|
|
928
|
+
} catch (err) {
|
|
929
|
+
return resolvePromise({
|
|
930
|
+
success: false,
|
|
931
|
+
output: "",
|
|
932
|
+
error: `spawn copilot-cli failed: ${err.message}`,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
let stdout = "";
|
|
937
|
+
let stderr = "";
|
|
938
|
+
|
|
939
|
+
child.stdout.on("data", (chunk) => (stdout += chunk.toString()));
|
|
940
|
+
child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
|
|
941
|
+
|
|
942
|
+
const timer = setTimeout(() => {
|
|
943
|
+
try {
|
|
944
|
+
child.kill("SIGTERM");
|
|
945
|
+
} catch {
|
|
946
|
+
/* best effort */
|
|
947
|
+
}
|
|
948
|
+
resolvePromise({
|
|
949
|
+
success: false,
|
|
950
|
+
output: stdout,
|
|
951
|
+
error: `timeout after ${timeoutMs}ms`,
|
|
952
|
+
});
|
|
953
|
+
}, timeoutMs);
|
|
954
|
+
|
|
955
|
+
child.on("error", (err) => {
|
|
956
|
+
clearTimeout(timer);
|
|
957
|
+
resolvePromise({
|
|
958
|
+
success: false,
|
|
959
|
+
output: stdout,
|
|
960
|
+
error: err.message,
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
child.on("exit", (code) => {
|
|
965
|
+
clearTimeout(timer);
|
|
966
|
+
resolvePromise({
|
|
967
|
+
success: code === 0,
|
|
968
|
+
output: stdout + (stderr ? "\n" + stderr : ""),
|
|
969
|
+
error: code !== 0 ? `exit code ${code}` : null,
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
}
|