@yr-kits/dev-copilot 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/index.d.mts +2 -0
- package/dist/app/index.mjs +4 -0
- package/dist/bin/bridge.mjs +4 -916
- package/dist/bin/bridge.mjs.map +1 -1
- package/dist/bin/dev-copilot.mjs +13 -3
- package/dist/bin/dev-copilot.mjs.map +1 -1
- package/dist/bridge/app/index.d.mts +24 -0
- package/dist/bridge/app/index.d.mts.map +1 -0
- package/dist/bridge/app/index.mjs +3 -0
- package/dist/{types/index.d.mts → copilot-D8--qKgC.d.mts} +3 -3
- package/dist/copilot-D8--qKgC.d.mts.map +1 -0
- package/dist/copilot-overlay-ClRoIHew.mjs +1129 -0
- package/dist/copilot-overlay-ClRoIHew.mjs.map +1 -0
- package/dist/copilot-overlay-NlUgrMjy.d.mts +7 -0
- package/dist/copilot-overlay-NlUgrMjy.d.mts.map +1 -0
- package/dist/dev-copilot-context-CA9bqV_U.mjs +35 -0
- package/dist/dev-copilot-context-CA9bqV_U.mjs.map +1 -0
- package/dist/dev-copilot-provider-CP_gJvWG.d.mts +22 -0
- package/dist/dev-copilot-provider-CP_gJvWG.d.mts.map +1 -0
- package/dist/dev-copilot-provider-D0_wEEem.mjs +14 -0
- package/dist/dev-copilot-provider-D0_wEEem.mjs.map +1 -0
- package/dist/index.d.mts +5 -33
- package/dist/index.mjs +6 -863
- package/dist/run-bridge-cli-DVLGcVgq.mjs +1510 -0
- package/dist/run-bridge-cli-DVLGcVgq.mjs.map +1 -0
- package/dist/types.d.mts +2 -0
- package/dist/widgets/copilot-overlay/index.d.mts +2 -0
- package/dist/widgets/copilot-overlay/index.mjs +4 -0
- package/package.json +25 -7
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/types/index.d.mts.map +0 -1
- /package/dist/{types/index.mjs → types.mjs} +0 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { promises } from "node:fs";
|
|
4
|
+
import path, { delimiter, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import os, { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { access, copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
|
|
10
|
+
//#region src/bridge/shared/config/bridge-config.ts
|
|
11
|
+
const DEFAULT_ALLOWED_PATHS = [
|
|
12
|
+
"app",
|
|
13
|
+
"src",
|
|
14
|
+
"widgets",
|
|
15
|
+
"features",
|
|
16
|
+
"entities",
|
|
17
|
+
"shared",
|
|
18
|
+
"components"
|
|
19
|
+
];
|
|
20
|
+
const createDevCopilotBridgeConfig = (config) => {
|
|
21
|
+
return {
|
|
22
|
+
rootDir: config.rootDir,
|
|
23
|
+
host: config.host ?? "127.0.0.1",
|
|
24
|
+
port: config.port ?? 3339,
|
|
25
|
+
corsOrigin: config.corsOrigin ?? "*",
|
|
26
|
+
agent: config.agent ?? "codex",
|
|
27
|
+
allowedPaths: config.allowedPaths ?? DEFAULT_ALLOWED_PATHS
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/bridge/shared/lib/temp-path.ts
|
|
33
|
+
const createTempPath = (prefix, extension = "") => {
|
|
34
|
+
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
35
|
+
return path.join(os.tmpdir(), `${prefix}-${suffix}${extension}`);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/bridge/lib/patch.ts
|
|
40
|
+
const execFileAsync$1 = promisify(execFile);
|
|
41
|
+
const getChangedFiles = (patchPreview) => {
|
|
42
|
+
const regex = /^\+\+\+ b\/(.+)$/gm;
|
|
43
|
+
const changedFiles = /* @__PURE__ */ new Set();
|
|
44
|
+
let match = regex.exec(patchPreview);
|
|
45
|
+
while (match) {
|
|
46
|
+
changedFiles.add(match[1]);
|
|
47
|
+
match = regex.exec(patchPreview);
|
|
48
|
+
}
|
|
49
|
+
return [...changedFiles];
|
|
50
|
+
};
|
|
51
|
+
const getGitApplyDirectoryArgs = async (cwd) => {
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await execFileAsync$1("git", ["rev-parse", "--show-prefix"], { cwd });
|
|
54
|
+
const prefix = stdout.trim().replace(/\/$/, "");
|
|
55
|
+
return prefix ? ["--directory", prefix] : [];
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const withOriginalLineIndent = (content, oldText, newText) => {
|
|
61
|
+
if (!newText.includes("\n")) return newText;
|
|
62
|
+
const matchIndex = content.indexOf(oldText);
|
|
63
|
+
if (matchIndex < 0) return newText;
|
|
64
|
+
const lineStartIndex = content.lastIndexOf("\n", matchIndex) + 1;
|
|
65
|
+
const indent = content.slice(lineStartIndex, matchIndex).match(/^[ \t]*/)?.[0] ?? "";
|
|
66
|
+
return indent ? newText.replaceAll("\n", `\n${indent}`) : newText;
|
|
67
|
+
};
|
|
68
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
const createWhitespaceTolerantPattern = (value) => {
|
|
70
|
+
const tokens = value.trim().split(/\s+/).filter(Boolean);
|
|
71
|
+
if (!tokens.length) return null;
|
|
72
|
+
return new RegExp(tokens.map(escapeRegExp).join("\\s+"));
|
|
73
|
+
};
|
|
74
|
+
const replaceText = (content, oldText, newText) => {
|
|
75
|
+
if (content.includes(oldText)) return {
|
|
76
|
+
replaced: true,
|
|
77
|
+
content: content.replace(oldText, withOriginalLineIndent(content, oldText, newText))
|
|
78
|
+
};
|
|
79
|
+
const match = createWhitespaceTolerantPattern(oldText)?.exec(content);
|
|
80
|
+
if (!match) return {
|
|
81
|
+
replaced: false,
|
|
82
|
+
content
|
|
83
|
+
};
|
|
84
|
+
const matchedText = match[0];
|
|
85
|
+
return {
|
|
86
|
+
replaced: true,
|
|
87
|
+
content: content.replace(matchedText, withOriginalLineIndent(content, matchedText, newText))
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const validatePatchPaths = (patchPreview, validatePath) => {
|
|
91
|
+
const changedFiles = getChangedFiles(patchPreview);
|
|
92
|
+
if (!changedFiles.length) throw new Error("패치에서 변경 파일을 찾을 수 없습니다.");
|
|
93
|
+
changedFiles.forEach(validatePath);
|
|
94
|
+
return changedFiles;
|
|
95
|
+
};
|
|
96
|
+
const checkUnifiedPatch = async (patchPreview, cwd) => {
|
|
97
|
+
const tempFilePath = createTempPath("dev-copilot-check", ".patch");
|
|
98
|
+
await promises.writeFile(tempFilePath, patchPreview, "utf-8");
|
|
99
|
+
try {
|
|
100
|
+
await execFileAsync$1("git", [
|
|
101
|
+
"apply",
|
|
102
|
+
"--check",
|
|
103
|
+
...await getGitApplyDirectoryArgs(cwd),
|
|
104
|
+
tempFilePath
|
|
105
|
+
], { cwd });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const detail = error instanceof Error && "stderr" in error ? String(error.stderr) : error instanceof Error ? error.message : "알 수 없는 patch 검증 오류";
|
|
108
|
+
throw new Error(`적용 가능한 diff를 생성하지 못했습니다: ${detail.trim()}`);
|
|
109
|
+
} finally {
|
|
110
|
+
await promises.rm(tempFilePath, { force: true });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const createGitPatch = async (filePath, oldContent, newContent) => {
|
|
114
|
+
const tempDir = createTempPath("dev-copilot-diff");
|
|
115
|
+
const oldFilePath = path.join(tempDir, "old", filePath);
|
|
116
|
+
const newFilePath = path.join(tempDir, "new", filePath);
|
|
117
|
+
await promises.mkdir(path.dirname(oldFilePath), { recursive: true });
|
|
118
|
+
await promises.mkdir(path.dirname(newFilePath), { recursive: true });
|
|
119
|
+
await promises.writeFile(oldFilePath, oldContent, "utf-8");
|
|
120
|
+
await promises.writeFile(newFilePath, newContent, "utf-8");
|
|
121
|
+
try {
|
|
122
|
+
const { stdout } = await execFileAsync$1("git", [
|
|
123
|
+
"diff",
|
|
124
|
+
"--no-index",
|
|
125
|
+
"--no-ext-diff",
|
|
126
|
+
"--src-prefix=a/",
|
|
127
|
+
"--dst-prefix=b/",
|
|
128
|
+
"--",
|
|
129
|
+
path.join("old", filePath),
|
|
130
|
+
path.join("new", filePath)
|
|
131
|
+
], { cwd: tempDir }).catch((error) => {
|
|
132
|
+
const typedError = error;
|
|
133
|
+
if (typedError.code === 1 && typedError.stdout) return { stdout: typedError.stdout };
|
|
134
|
+
throw error;
|
|
135
|
+
});
|
|
136
|
+
return stdout.replaceAll(`a/old/${filePath}`, `a/${filePath}`).replaceAll(`b/new/${filePath}`, `b/${filePath}`);
|
|
137
|
+
} finally {
|
|
138
|
+
await promises.rm(tempDir, {
|
|
139
|
+
force: true,
|
|
140
|
+
recursive: true
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const createPatchFromTextReplacements = async (replacements, cwd, validatePath) => {
|
|
145
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
146
|
+
for (const replacement of replacements) {
|
|
147
|
+
const normalizedPath = validatePath(replacement.path);
|
|
148
|
+
const current = byPath.get(normalizedPath) ?? [];
|
|
149
|
+
current.push(replacement);
|
|
150
|
+
byPath.set(normalizedPath, current);
|
|
151
|
+
}
|
|
152
|
+
const patches = [];
|
|
153
|
+
for (const [filePath, fileReplacements] of byPath.entries()) {
|
|
154
|
+
const absolutePath = path.join(cwd, filePath);
|
|
155
|
+
const oldContent = await promises.readFile(absolutePath, "utf-8");
|
|
156
|
+
let newContent = oldContent;
|
|
157
|
+
for (const replacement of fileReplacements) {
|
|
158
|
+
const result = replaceText(newContent, replacement.oldText, replacement.newText);
|
|
159
|
+
if (!result.replaced) throw new Error(`원문을 파일에서 찾을 수 없습니다: ${filePath}`);
|
|
160
|
+
newContent = result.content;
|
|
161
|
+
}
|
|
162
|
+
if (newContent === oldContent) continue;
|
|
163
|
+
patches.push(await createGitPatch(filePath, oldContent, newContent));
|
|
164
|
+
}
|
|
165
|
+
const patchPreview = patches.join("\n");
|
|
166
|
+
if (!patchPreview.trim()) throw new Error("변경할 내용이 없습니다.");
|
|
167
|
+
return patchPreview;
|
|
168
|
+
};
|
|
169
|
+
const applyUnifiedPatch = async (patchPreview, cwd, actor) => {
|
|
170
|
+
const tempFilePath = createTempPath("dev-copilot", ".patch");
|
|
171
|
+
await promises.writeFile(tempFilePath, patchPreview, "utf-8");
|
|
172
|
+
try {
|
|
173
|
+
const directoryArgs = await getGitApplyDirectoryArgs(cwd);
|
|
174
|
+
const { stdout: statOutput } = await execFileAsync$1("git", [
|
|
175
|
+
"apply",
|
|
176
|
+
"--numstat",
|
|
177
|
+
...directoryArgs,
|
|
178
|
+
tempFilePath
|
|
179
|
+
], { cwd });
|
|
180
|
+
if (!statOutput.trim()) throw new Error("patch가 실제 파일 변경을 만들지 않았습니다.");
|
|
181
|
+
await execFileAsync$1("git", [
|
|
182
|
+
"apply",
|
|
183
|
+
"--check",
|
|
184
|
+
...directoryArgs,
|
|
185
|
+
tempFilePath
|
|
186
|
+
], { cwd });
|
|
187
|
+
await execFileAsync$1("git", [
|
|
188
|
+
"apply",
|
|
189
|
+
...directoryArgs,
|
|
190
|
+
tempFilePath
|
|
191
|
+
], { cwd });
|
|
192
|
+
return {
|
|
193
|
+
actor,
|
|
194
|
+
changedFiles: getChangedFiles(patchPreview)
|
|
195
|
+
};
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const detail = error instanceof Error && "stderr" in error ? String(error.stderr) : error instanceof Error ? error.message : "알 수 없는 patch 적용 오류";
|
|
198
|
+
throw new Error(`patch 적용에 실패했습니다: ${detail.trim()}`);
|
|
199
|
+
} finally {
|
|
200
|
+
await promises.rm(tempFilePath, { force: true });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/bridge/lib/guards.ts
|
|
206
|
+
const resolveAndValidatePath = (filePath, allowedPaths, rootDir) => {
|
|
207
|
+
if (!filePath) throw new Error("허용되지 않은 파일 경로입니다.");
|
|
208
|
+
let normalizedInput = filePath.replaceAll("\\", "/");
|
|
209
|
+
if (rootDir) {
|
|
210
|
+
const normalizedRootDir = rootDir.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
211
|
+
if (normalizedInput.startsWith(`${normalizedRootDir}/`)) normalizedInput = normalizedInput.slice(normalizedRootDir.length + 1);
|
|
212
|
+
}
|
|
213
|
+
if (path.isAbsolute(normalizedInput) || normalizedInput.includes("..")) throw new Error("허용되지 않은 파일 경로입니다.");
|
|
214
|
+
const normalized = normalizedInput;
|
|
215
|
+
const firstSegment = normalized.split("/")[0];
|
|
216
|
+
if (!(allowedPaths.includes(".") || allowedPaths.includes("*")) && !allowedPaths.includes(firstSegment)) throw new Error(`허용 경로 외 파일은 수정할 수 없습니다: ${firstSegment}`);
|
|
217
|
+
return normalized;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/bridge/lib/store.ts
|
|
222
|
+
const patchStore = /* @__PURE__ */ new Map();
|
|
223
|
+
const TTL_MS = 1e3 * 60 * 15;
|
|
224
|
+
const pruneExpired = () => {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
for (const [key, value] of patchStore.entries()) if (now - value.createdAt > TTL_MS) patchStore.delete(key);
|
|
227
|
+
};
|
|
228
|
+
const createProposedPatch = (patchPreview, allowedPaths) => {
|
|
229
|
+
pruneExpired();
|
|
230
|
+
const patchId = randomUUID();
|
|
231
|
+
const approvalToken = `approve:${patchId}`;
|
|
232
|
+
patchStore.set(patchId, {
|
|
233
|
+
patchId,
|
|
234
|
+
approvalToken,
|
|
235
|
+
patchPreview,
|
|
236
|
+
allowedPaths,
|
|
237
|
+
createdAt: Date.now()
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
patchId,
|
|
241
|
+
approvalToken
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
const getProposedPatch = (patchId, approvalToken) => {
|
|
245
|
+
pruneExpired();
|
|
246
|
+
const item = patchStore.get(patchId);
|
|
247
|
+
if (!item || item.approvalToken !== approvalToken) return null;
|
|
248
|
+
return item;
|
|
249
|
+
};
|
|
250
|
+
const deleteProposedPatch = (patchId) => {
|
|
251
|
+
patchStore.delete(patchId);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/bridge/features/patch-flow/patch-flow-service.ts
|
|
256
|
+
const sanitizeAllowedPaths = (fileHints) => {
|
|
257
|
+
if (!fileHints?.length) return null;
|
|
258
|
+
const sanitized = fileHints.map((value) => value.trim()).filter(Boolean).filter((value) => value === "." || value === "*" || /^[A-Za-z0-9._-]+$/.test(value));
|
|
259
|
+
return sanitized.length ? Array.from(new Set(sanitized)) : null;
|
|
260
|
+
};
|
|
261
|
+
const resolveAllowedPathsWithSrcFallback = (config, fileHints) => {
|
|
262
|
+
const resolved = sanitizeAllowedPaths(fileHints) ?? config.allowedPaths;
|
|
263
|
+
if (!resolved.includes(".") && !resolved.includes("*") && !resolved.includes("src")) return [...resolved, "src"];
|
|
264
|
+
return resolved;
|
|
265
|
+
};
|
|
266
|
+
const buildPatchPreview = async (changes, config, allowedPaths) => {
|
|
267
|
+
if (!changes?.length) return "";
|
|
268
|
+
return createPatchFromTextReplacements(changes, config.rootDir, (filePath) => resolveAndValidatePath(filePath, allowedPaths, config.rootDir));
|
|
269
|
+
};
|
|
270
|
+
const validatePatchPreview = async (patchPreview, config, allowedPaths) => {
|
|
271
|
+
validatePatchPaths(patchPreview, (filePath) => resolveAndValidatePath(filePath, allowedPaths, config.rootDir));
|
|
272
|
+
await checkUnifiedPatch(patchPreview, config.rootDir);
|
|
273
|
+
};
|
|
274
|
+
const saveProposedPatch = (patchPreview, allowedPaths) => {
|
|
275
|
+
return createProposedPatch(patchPreview, allowedPaths);
|
|
276
|
+
};
|
|
277
|
+
const applySavedPatch = async (config, patchId, approvalToken) => {
|
|
278
|
+
const proposedPatch = getProposedPatch(patchId, approvalToken);
|
|
279
|
+
if (!proposedPatch) throw new Error("유효하지 않거나 만료된 patchId입니다.");
|
|
280
|
+
validatePatchPaths(proposedPatch.patchPreview, (filePath) => resolveAndValidatePath(filePath, proposedPatch.allowedPaths?.length ? proposedPatch.allowedPaths : config.allowedPaths, config.rootDir));
|
|
281
|
+
const result = await applyUnifiedPatch(proposedPatch.patchPreview, config.rootDir, "local-codex-bridge");
|
|
282
|
+
deleteProposedPatch(patchId);
|
|
283
|
+
return result;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/bridge/shared/http/read-json-body.ts
|
|
288
|
+
const readJsonBody = async (request) => {
|
|
289
|
+
const chunks = [];
|
|
290
|
+
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
291
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
292
|
+
return JSON.parse(raw);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/bridge/shared/http/respond.ts
|
|
297
|
+
const createCorsHeaders = (config) => ({
|
|
298
|
+
"Access-Control-Allow-Origin": config.corsOrigin,
|
|
299
|
+
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
300
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
301
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
302
|
+
});
|
|
303
|
+
const sendJson = (response, config, statusCode, payload) => {
|
|
304
|
+
response.writeHead(statusCode, createCorsHeaders(config));
|
|
305
|
+
response.end(JSON.stringify(payload));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/bridge/features/apply/apply-route.ts
|
|
310
|
+
const toPatchSummary = (changedFiles) => {
|
|
311
|
+
return changedFiles.length === 1 ? "1개 파일에 수정이 반영되었습니다." : `${changedFiles.length}개 파일에 수정이 반영되었습니다.`;
|
|
312
|
+
};
|
|
313
|
+
const handleApplyRoute = async (request, response, config) => {
|
|
314
|
+
const payload = await readJsonBody(request);
|
|
315
|
+
if (!payload.patchId || !payload.approvalToken) {
|
|
316
|
+
sendJson(response, config, 400, { error: "patchId와 approvalToken이 필요합니다." });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const result = await applySavedPatch(config, payload.patchId, payload.approvalToken);
|
|
320
|
+
sendJson(response, config, 200, {
|
|
321
|
+
applied: true,
|
|
322
|
+
changedFiles: result.changedFiles,
|
|
323
|
+
summary: toPatchSummary(result.changedFiles)
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/bridge/internal/agents/edit-response-schema.ts
|
|
329
|
+
const agentEditResponseSchema = {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
message: { type: "string" },
|
|
333
|
+
warnings: {
|
|
334
|
+
type: "array",
|
|
335
|
+
items: { type: "string" }
|
|
336
|
+
},
|
|
337
|
+
changes: {
|
|
338
|
+
type: "array",
|
|
339
|
+
minItems: 1,
|
|
340
|
+
items: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
path: { type: "string" },
|
|
344
|
+
oldText: { type: "string" },
|
|
345
|
+
newText: { type: "string" }
|
|
346
|
+
},
|
|
347
|
+
required: [
|
|
348
|
+
"path",
|
|
349
|
+
"oldText",
|
|
350
|
+
"newText"
|
|
351
|
+
],
|
|
352
|
+
additionalProperties: false
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
required: [
|
|
357
|
+
"message",
|
|
358
|
+
"warnings",
|
|
359
|
+
"changes"
|
|
360
|
+
],
|
|
361
|
+
additionalProperties: false
|
|
362
|
+
};
|
|
363
|
+
const agentEditResponseSchemaJson = JSON.stringify(agentEditResponseSchema);
|
|
364
|
+
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region src/bridge/internal/agents/constants.ts
|
|
367
|
+
const DEFAULT_AGENT_TIMEOUT_MS = 12e4;
|
|
368
|
+
const DEFAULT_AGENT_MAX_BUFFER_BYTES = 1024 * 1024 * 5;
|
|
369
|
+
const STATUS_CHECK_TIMEOUT_MS = 1e4;
|
|
370
|
+
const QUICK_STATUS_CHECK_TIMEOUT_MS = 5e3;
|
|
371
|
+
const MODEL_STATUS_CHECK_TIMEOUT_MS = 3e4;
|
|
372
|
+
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/bridge/internal/agents/agent-response.ts
|
|
375
|
+
const findFirstString = (value) => {
|
|
376
|
+
if (typeof value === "string") {
|
|
377
|
+
const text = value.trim();
|
|
378
|
+
return text ? text : null;
|
|
379
|
+
}
|
|
380
|
+
if (Array.isArray(value)) {
|
|
381
|
+
for (const item of value) {
|
|
382
|
+
const found = findFirstString(item);
|
|
383
|
+
if (found) return found;
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
if (value && typeof value === "object") {
|
|
388
|
+
const record = value;
|
|
389
|
+
for (const key of [
|
|
390
|
+
"result",
|
|
391
|
+
"message",
|
|
392
|
+
"output",
|
|
393
|
+
"text",
|
|
394
|
+
"content"
|
|
395
|
+
]) if (key in record) {
|
|
396
|
+
const found = findFirstString(record[key]);
|
|
397
|
+
if (found) return found;
|
|
398
|
+
}
|
|
399
|
+
for (const nestedValue of Object.values(record)) {
|
|
400
|
+
const found = findFirstString(nestedValue);
|
|
401
|
+
if (found) return found;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
};
|
|
406
|
+
const normalizeAgentBridgeResponse = (value) => {
|
|
407
|
+
return {
|
|
408
|
+
message: typeof value.message === "string" ? value.message : "",
|
|
409
|
+
patchPreview: value.patchPreview,
|
|
410
|
+
changes: value.changes,
|
|
411
|
+
warnings: Array.isArray(value.warnings) ? value.warnings : []
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
const parseJsonLikeText = (text) => {
|
|
415
|
+
const trimmed = text.trim();
|
|
416
|
+
const jsonText = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1]?.trim() ?? trimmed;
|
|
417
|
+
return JSON.parse(jsonText);
|
|
418
|
+
};
|
|
419
|
+
const parseClaudeJsonOutput = (raw) => {
|
|
420
|
+
const parsed = JSON.parse(raw);
|
|
421
|
+
return {
|
|
422
|
+
parsed,
|
|
423
|
+
text: findFirstString(parsed) ?? ""
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
const parseClaudeEditResponse = (raw) => {
|
|
427
|
+
const { parsed, text } = parseClaudeJsonOutput(raw);
|
|
428
|
+
return normalizeAgentBridgeResponse(parsed.structured_output && typeof parsed.structured_output === "object" ? parsed.structured_output : parseJsonLikeText(text));
|
|
429
|
+
};
|
|
430
|
+
const parseCodexEditResponse = (raw) => {
|
|
431
|
+
return normalizeAgentBridgeResponse(parseJsonLikeText(raw));
|
|
432
|
+
};
|
|
433
|
+
const parseAnswerResponse = (rawOutput) => {
|
|
434
|
+
return {
|
|
435
|
+
message: rawOutput.trim(),
|
|
436
|
+
warnings: []
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/bridge/internal/agents/cli-error.ts
|
|
442
|
+
const extractCliErrorDetails = (error) => {
|
|
443
|
+
const record = error && typeof error === "object" ? error : null;
|
|
444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
445
|
+
const stderr = typeof record?.stderr === "string" ? record.stderr : "";
|
|
446
|
+
const stdout = typeof record?.stdout === "string" ? record.stdout : "";
|
|
447
|
+
const signal = typeof record?.signal === "string" ? record.signal : "";
|
|
448
|
+
const killed = record?.killed === true;
|
|
449
|
+
return {
|
|
450
|
+
message,
|
|
451
|
+
stderr,
|
|
452
|
+
stdout,
|
|
453
|
+
signal,
|
|
454
|
+
killed,
|
|
455
|
+
merged: [
|
|
456
|
+
message,
|
|
457
|
+
stderr,
|
|
458
|
+
stdout,
|
|
459
|
+
signal,
|
|
460
|
+
killed ? "killed" : ""
|
|
461
|
+
].map((part) => part.trim()).filter(Boolean).join("\n")
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
const isCliTimeout = (details) => {
|
|
465
|
+
return /SIGTERM|timed out|timeout/i.test(details.merged);
|
|
466
|
+
};
|
|
467
|
+
const isCliCommandMissing = (details) => {
|
|
468
|
+
return /ENOENT/.test(details.merged);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/bridge/internal/agents/run-cli.ts
|
|
473
|
+
const runCli = (command, args, options) => {
|
|
474
|
+
return new Promise((resolve, reject) => {
|
|
475
|
+
const maxBuffer = options.maxBuffer ?? DEFAULT_AGENT_MAX_BUFFER_BYTES;
|
|
476
|
+
const child = spawn(command, args, {
|
|
477
|
+
cwd: options.cwd,
|
|
478
|
+
stdio: [
|
|
479
|
+
"ignore",
|
|
480
|
+
"pipe",
|
|
481
|
+
"pipe"
|
|
482
|
+
],
|
|
483
|
+
env: {
|
|
484
|
+
...process.env,
|
|
485
|
+
NO_COLOR: "1",
|
|
486
|
+
...options.env
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
const stdoutChunks = [];
|
|
490
|
+
const stderrChunks = [];
|
|
491
|
+
let stdoutLength = 0;
|
|
492
|
+
let stderrLength = 0;
|
|
493
|
+
let settled = false;
|
|
494
|
+
const timeout = setTimeout(() => {
|
|
495
|
+
if (settled) return;
|
|
496
|
+
settled = true;
|
|
497
|
+
child.kill("SIGTERM");
|
|
498
|
+
const error = /* @__PURE__ */ new Error(`${command} 실행 시간이 초과되었습니다.`);
|
|
499
|
+
Object.assign(error, {
|
|
500
|
+
signal: "SIGTERM",
|
|
501
|
+
killed: true
|
|
502
|
+
});
|
|
503
|
+
reject(error);
|
|
504
|
+
}, options.timeoutMs);
|
|
505
|
+
const fail = (error) => {
|
|
506
|
+
if (settled) return;
|
|
507
|
+
settled = true;
|
|
508
|
+
clearTimeout(timeout);
|
|
509
|
+
child.kill("SIGTERM");
|
|
510
|
+
reject(error);
|
|
511
|
+
};
|
|
512
|
+
child.stdout.on("data", (chunk) => {
|
|
513
|
+
stdoutLength += chunk.length;
|
|
514
|
+
if (stdoutLength > maxBuffer) {
|
|
515
|
+
fail(/* @__PURE__ */ new Error(`${command} stdout이 허용된 크기를 초과했습니다.`));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
stdoutChunks.push(chunk);
|
|
519
|
+
});
|
|
520
|
+
child.stderr.on("data", (chunk) => {
|
|
521
|
+
stderrLength += chunk.length;
|
|
522
|
+
if (stderrLength > maxBuffer) {
|
|
523
|
+
fail(/* @__PURE__ */ new Error(`${command} stderr가 허용된 크기를 초과했습니다.`));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
stderrChunks.push(chunk);
|
|
527
|
+
});
|
|
528
|
+
child.on("error", (error) => {
|
|
529
|
+
fail(error);
|
|
530
|
+
});
|
|
531
|
+
child.on("close", (code, signal) => {
|
|
532
|
+
if (settled) return;
|
|
533
|
+
settled = true;
|
|
534
|
+
clearTimeout(timeout);
|
|
535
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
536
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
537
|
+
if (code === 0) {
|
|
538
|
+
resolve({
|
|
539
|
+
stdout,
|
|
540
|
+
stderr
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const error = new Error(stderr || `${command} 실행에 실패했습니다. code=${code ?? "unknown"}`);
|
|
545
|
+
Object.assign(error, {
|
|
546
|
+
stdout,
|
|
547
|
+
stderr,
|
|
548
|
+
signal,
|
|
549
|
+
killed: signal === "SIGTERM",
|
|
550
|
+
code
|
|
551
|
+
});
|
|
552
|
+
reject(error);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/bridge/internal/agents/claude-command.ts
|
|
559
|
+
let claudeCommandPromise = null;
|
|
560
|
+
const CLAUDE_SMOKE_MODEL = process.env.DEV_COPILOT_CLAUDE_MODEL ?? "haiku";
|
|
561
|
+
const getPathExecutableNames$1 = () => {
|
|
562
|
+
if (process.platform !== "win32") return ["claude"];
|
|
563
|
+
return ["claude", ...(process.env.PATHEXT?.split(";").filter(Boolean) ?? [
|
|
564
|
+
".EXE",
|
|
565
|
+
".CMD",
|
|
566
|
+
".BAT"
|
|
567
|
+
]).map((ext) => `claude${ext.toLowerCase()}`)];
|
|
568
|
+
};
|
|
569
|
+
const collectClaudeCandidates = () => {
|
|
570
|
+
const candidates = [];
|
|
571
|
+
const envCommand = process.env.DEV_COPILOT_CLAUDE_BIN?.trim();
|
|
572
|
+
if (envCommand) candidates.push({
|
|
573
|
+
command: envCommand,
|
|
574
|
+
source: "env"
|
|
575
|
+
});
|
|
576
|
+
const executableNames = getPathExecutableNames$1();
|
|
577
|
+
const pathCandidates = (process.env.PATH ?? "").split(delimiter).filter(Boolean).flatMap((pathDir) => executableNames.map((name) => join(pathDir, name)));
|
|
578
|
+
for (const command of pathCandidates) candidates.push({
|
|
579
|
+
command,
|
|
580
|
+
source: "path"
|
|
581
|
+
});
|
|
582
|
+
return Array.from(new Map(candidates.map((candidate) => [candidate.command, candidate])).values());
|
|
583
|
+
};
|
|
584
|
+
const canRunClaude = async (command) => {
|
|
585
|
+
try {
|
|
586
|
+
await runCli(command, [
|
|
587
|
+
"-p",
|
|
588
|
+
"--output-format",
|
|
589
|
+
"json",
|
|
590
|
+
"--model",
|
|
591
|
+
CLAUDE_SMOKE_MODEL,
|
|
592
|
+
"--tools",
|
|
593
|
+
"",
|
|
594
|
+
"--strict-mcp-config",
|
|
595
|
+
"--mcp-config",
|
|
596
|
+
"{\"mcpServers\":{}}",
|
|
597
|
+
"--",
|
|
598
|
+
"OK만 출력해줘."
|
|
599
|
+
], {
|
|
600
|
+
cwd: process.cwd(),
|
|
601
|
+
timeoutMs: QUICK_STATUS_CHECK_TIMEOUT_MS,
|
|
602
|
+
maxBuffer: 1024 * 128
|
|
603
|
+
});
|
|
604
|
+
return true;
|
|
605
|
+
} catch {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
const resolveClaudeCommand = async () => {
|
|
610
|
+
if (claudeCommandPromise) return claudeCommandPromise;
|
|
611
|
+
claudeCommandPromise = (async () => {
|
|
612
|
+
const candidates = collectClaudeCandidates();
|
|
613
|
+
const envCandidate = candidates.find((candidate) => candidate.source === "env");
|
|
614
|
+
if (envCandidate) return envCandidate.command;
|
|
615
|
+
for (const candidate of candidates) if (await canRunClaude(candidate.command)) return candidate.command;
|
|
616
|
+
return process.env.DEV_COPILOT_CLAUDE_BIN?.trim() || "claude";
|
|
617
|
+
})();
|
|
618
|
+
return claudeCommandPromise;
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/bridge/internal/prompts.ts
|
|
623
|
+
const buildAgentPrompt = (request) => {
|
|
624
|
+
if (request.mode === "answer") return [
|
|
625
|
+
"Answer the user's web overlay request.",
|
|
626
|
+
"Do not modify files.",
|
|
627
|
+
"Use the provided project context when it is relevant.",
|
|
628
|
+
"Respond in Korean.",
|
|
629
|
+
"Keep the answer concise and actionable.",
|
|
630
|
+
"",
|
|
631
|
+
`Current route: ${request.route ?? "unknown"}`,
|
|
632
|
+
`Selected text:\n${request.selectedText || "(none)"}`,
|
|
633
|
+
`User prompt:\n${request.prompt}`,
|
|
634
|
+
`Previous AI response:\n${request.previousResponse ?? "(none)"}`,
|
|
635
|
+
`Project context:\n${request.projectContext ?? "(none)"}`
|
|
636
|
+
].join("\n");
|
|
637
|
+
return [
|
|
638
|
+
"You are a local code-edit proposal generator called from a web overlay.",
|
|
639
|
+
"Do not modify files directly.",
|
|
640
|
+
"Return JSON only.",
|
|
641
|
+
"Do not generate unified diff directly.",
|
|
642
|
+
"Put repository-relative file path(path), exact old text(oldText), and replacement text(newText) into the changes array.",
|
|
643
|
+
"Never use absolute paths. The path must be relative to the repository root, for example src/features/article/model/data.ts.",
|
|
644
|
+
"oldText must exactly match the file content and must not use ellipsis.",
|
|
645
|
+
"If the selected text spans multiple rendered lines, use the actual file text from project context for oldText.",
|
|
646
|
+
"newText must be meaningfully different from oldText.",
|
|
647
|
+
"Ground the proposal in the selected text, user prompt, and provided project context.",
|
|
648
|
+
"Write the message field in Korean.",
|
|
649
|
+
"",
|
|
650
|
+
`Current route: ${request.route ?? "unknown"}`,
|
|
651
|
+
`Allowed path hints: ${(request.fileHints ?? []).join(", ") || "(none)"}`,
|
|
652
|
+
`Selected text:\n${request.selectedText || "(none)"}`,
|
|
653
|
+
`User prompt:\n${request.prompt}`,
|
|
654
|
+
`Previous AI response:\n${request.previousResponse ?? "(none)"}`,
|
|
655
|
+
`Project context:\n${request.projectContext ?? "(none)"}`
|
|
656
|
+
].join("\n");
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/bridge/entities/agent/status.ts
|
|
661
|
+
const createAuthenticatedStatus = (params) => ({
|
|
662
|
+
available: true,
|
|
663
|
+
authenticated: true,
|
|
664
|
+
agent: params.agent,
|
|
665
|
+
message: params.message,
|
|
666
|
+
model: params.model
|
|
667
|
+
});
|
|
668
|
+
const createLoginRequiredStatus = (params) => ({
|
|
669
|
+
available: params.available ?? true,
|
|
670
|
+
authenticated: false,
|
|
671
|
+
agent: params.agent,
|
|
672
|
+
message: params.message,
|
|
673
|
+
loginCommand: params.loginCommand
|
|
674
|
+
});
|
|
675
|
+
const createUnavailableStatus = (params) => ({
|
|
676
|
+
available: false,
|
|
677
|
+
authenticated: false,
|
|
678
|
+
agent: params.agent,
|
|
679
|
+
message: params.message
|
|
680
|
+
});
|
|
681
|
+
const createUnauthenticatedStatus = (params) => ({
|
|
682
|
+
available: params.available ?? true,
|
|
683
|
+
authenticated: false,
|
|
684
|
+
agent: params.agent,
|
|
685
|
+
message: params.message,
|
|
686
|
+
loginCommand: params.loginCommand
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
//#endregion
|
|
690
|
+
//#region src/bridge/internal/agents/claude-adapter.ts
|
|
691
|
+
const CLAUDE_MODEL = process.env.DEV_COPILOT_CLAUDE_MODEL ?? "haiku";
|
|
692
|
+
const CLAUDE_TIMEOUT_MS = Number(process.env.DEV_COPILOT_CLAUDE_TIMEOUT_MS ?? DEFAULT_AGENT_TIMEOUT_MS);
|
|
693
|
+
const AUTH_ERROR_PATTERN = /401|authentication|invalid authentication credentials|please run \/login|claude\s*\/login|로그인/i;
|
|
694
|
+
const toClaudeErrorMessage = (error) => {
|
|
695
|
+
const details = extractCliErrorDetails(error);
|
|
696
|
+
if (isCliTimeout(details)) return "Claude Code 응답 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.";
|
|
697
|
+
if (AUTH_ERROR_PATTERN.test(details.merged)) return "Claude Code 로그인이 필요합니다. 터미널에서 `claude /login`을 실행해 주세요.";
|
|
698
|
+
if (isCliCommandMissing(details)) return "Claude CLI를 찾을 수 없습니다. Claude Code CLI 설치 상태를 확인해 주세요.";
|
|
699
|
+
return details.message;
|
|
700
|
+
};
|
|
701
|
+
const claudeAdapter = {
|
|
702
|
+
agent: "claude",
|
|
703
|
+
async run(request) {
|
|
704
|
+
const claudeCommand = await resolveClaudeCommand();
|
|
705
|
+
const prompt = buildAgentPrompt(request);
|
|
706
|
+
const args = [
|
|
707
|
+
"-p",
|
|
708
|
+
"--output-format",
|
|
709
|
+
"json",
|
|
710
|
+
"--model",
|
|
711
|
+
CLAUDE_MODEL,
|
|
712
|
+
"--tools",
|
|
713
|
+
"",
|
|
714
|
+
"--strict-mcp-config",
|
|
715
|
+
"--mcp-config",
|
|
716
|
+
"{\"mcpServers\":{}}",
|
|
717
|
+
...request.mode === "edit" ? ["--json-schema", agentEditResponseSchemaJson] : [],
|
|
718
|
+
"--",
|
|
719
|
+
prompt
|
|
720
|
+
];
|
|
721
|
+
try {
|
|
722
|
+
const { stdout } = await runCli(claudeCommand, args, {
|
|
723
|
+
cwd: request.cwd,
|
|
724
|
+
timeoutMs: Number(process.env.DEV_COPILOT_AGENT_TIMEOUT_MS ?? CLAUDE_TIMEOUT_MS)
|
|
725
|
+
});
|
|
726
|
+
if (request.mode === "answer") {
|
|
727
|
+
const { text } = parseClaudeJsonOutput(stdout);
|
|
728
|
+
return parseAnswerResponse(text);
|
|
729
|
+
}
|
|
730
|
+
return parseClaudeEditResponse(stdout);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
throw new Error(toClaudeErrorMessage(error));
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
async getStatus(cwd) {
|
|
736
|
+
try {
|
|
737
|
+
await runCli(await resolveClaudeCommand(), ["--version"], {
|
|
738
|
+
cwd,
|
|
739
|
+
timeoutMs: QUICK_STATUS_CHECK_TIMEOUT_MS,
|
|
740
|
+
maxBuffer: 1024 * 32
|
|
741
|
+
});
|
|
742
|
+
return createAuthenticatedStatus({
|
|
743
|
+
agent: "claude",
|
|
744
|
+
message: "Claude Code CLI에 로그인되어 있습니다.",
|
|
745
|
+
model: CLAUDE_MODEL
|
|
746
|
+
});
|
|
747
|
+
} catch (error) {
|
|
748
|
+
const details = extractCliErrorDetails(error);
|
|
749
|
+
if (isCliCommandMissing(details)) return createUnavailableStatus({
|
|
750
|
+
agent: "claude",
|
|
751
|
+
message: "Claude CLI를 찾을 수 없습니다."
|
|
752
|
+
});
|
|
753
|
+
if (AUTH_ERROR_PATTERN.test(details.merged)) return createLoginRequiredStatus({
|
|
754
|
+
agent: "claude",
|
|
755
|
+
message: "Claude Code 로그인이 필요합니다.",
|
|
756
|
+
loginCommand: "claude /login"
|
|
757
|
+
});
|
|
758
|
+
return createUnauthenticatedStatus({
|
|
759
|
+
agent: "claude",
|
|
760
|
+
message: isCliTimeout(details) ? "Claude Code 상태 확인 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요." : `Claude Code 상태 확인에 실패했습니다: ${details.message}`,
|
|
761
|
+
loginCommand: isCliTimeout(details) ? void 0 : "claude /login"
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region src/bridge/internal/agents/codex-command.ts
|
|
769
|
+
let codexCommandPromise = null;
|
|
770
|
+
const canAccess = async (path) => {
|
|
771
|
+
try {
|
|
772
|
+
await access(path);
|
|
773
|
+
return true;
|
|
774
|
+
} catch {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
const getPathExecutableNames = () => {
|
|
779
|
+
if (process.platform !== "win32") return ["codex"];
|
|
780
|
+
return ["codex", ...(process.env.PATHEXT?.split(";").filter(Boolean) ?? [
|
|
781
|
+
".EXE",
|
|
782
|
+
".CMD",
|
|
783
|
+
".BAT"
|
|
784
|
+
]).map((ext) => `codex${ext.toLowerCase()}`)];
|
|
785
|
+
};
|
|
786
|
+
const collectCodexCandidates = async () => {
|
|
787
|
+
const executableNames = getPathExecutableNames();
|
|
788
|
+
const candidates = [];
|
|
789
|
+
const pathCandidates = (process.env.PATH ?? "").split(delimiter).filter(Boolean).flatMap((pathDir) => executableNames.map((name) => join(pathDir, name)));
|
|
790
|
+
for (const command of pathCandidates) if (await canAccess(command)) candidates.push({ command });
|
|
791
|
+
return Array.from(new Map(candidates.map((candidate) => [candidate.command, candidate])).values());
|
|
792
|
+
};
|
|
793
|
+
const parseCodexVersion = (output) => {
|
|
794
|
+
const version = output.match(/\d+\.\d+\.\d+(?:[-+][^\s]+)?/)?.[0];
|
|
795
|
+
if (!version) return null;
|
|
796
|
+
return { version };
|
|
797
|
+
};
|
|
798
|
+
const resolveCandidate = async (candidate) => {
|
|
799
|
+
const { stdout, stderr } = await runCli(candidate.command, ["--version"], {
|
|
800
|
+
cwd: process.cwd(),
|
|
801
|
+
timeoutMs: STATUS_CHECK_TIMEOUT_MS,
|
|
802
|
+
maxBuffer: 1024 * 128
|
|
803
|
+
});
|
|
804
|
+
const parsedVersion = parseCodexVersion(`${stdout}\n${stderr}`);
|
|
805
|
+
if (!parsedVersion) return null;
|
|
806
|
+
return {
|
|
807
|
+
...candidate,
|
|
808
|
+
...parsedVersion
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
const resolveCodexCommand = async () => {
|
|
812
|
+
if (codexCommandPromise) return codexCommandPromise;
|
|
813
|
+
codexCommandPromise = (async () => {
|
|
814
|
+
const candidates = await collectCodexCandidates();
|
|
815
|
+
const available = (await Promise.all(candidates.map(async (candidate) => {
|
|
816
|
+
try {
|
|
817
|
+
return await resolveCandidate(candidate);
|
|
818
|
+
} catch {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
}))).filter((candidate) => Boolean(candidate));
|
|
822
|
+
if (!available.length) throw new Error("Codex CLI를 찾을 수 없습니다.");
|
|
823
|
+
return available[0].command;
|
|
824
|
+
})();
|
|
825
|
+
return codexCommandPromise;
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
//#endregion
|
|
829
|
+
//#region src/bridge/internal/agents/codex-home.ts
|
|
830
|
+
let codexExecutionEnvPromise = null;
|
|
831
|
+
const copyIfExists = async (sourcePath, targetPath) => {
|
|
832
|
+
try {
|
|
833
|
+
await access(sourcePath);
|
|
834
|
+
await copyFile(sourcePath, targetPath);
|
|
835
|
+
} catch {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
const writeIsolatedConfig = async (targetPath) => {
|
|
840
|
+
try {
|
|
841
|
+
if (await readFile(targetPath, "utf-8") === "") return;
|
|
842
|
+
} catch {}
|
|
843
|
+
await writeFile(targetPath, "", "utf-8");
|
|
844
|
+
};
|
|
845
|
+
const resolveSourceCodexHome = (env = process.env) => {
|
|
846
|
+
return env.CODEX_HOME?.trim() || join(homedir(), ".codex");
|
|
847
|
+
};
|
|
848
|
+
const createIsolatedCodexHome = async (sourceCodexHome) => {
|
|
849
|
+
const isolatedCodexHome = await mkdtemp(join(tmpdir(), "dev-copilot-codex-home-"));
|
|
850
|
+
await copyIfExists(join(sourceCodexHome, "auth.json"), join(isolatedCodexHome, "auth.json"));
|
|
851
|
+
await copyIfExists(join(sourceCodexHome, ".credentials.json"), join(isolatedCodexHome, ".credentials.json"));
|
|
852
|
+
await writeIsolatedConfig(join(isolatedCodexHome, "config.toml"));
|
|
853
|
+
return isolatedCodexHome;
|
|
854
|
+
};
|
|
855
|
+
const getCodexExecutionEnv = async (options) => {
|
|
856
|
+
if (codexExecutionEnvPromise) return codexExecutionEnvPromise;
|
|
857
|
+
codexExecutionEnvPromise = (async () => {
|
|
858
|
+
return { CODEX_HOME: await createIsolatedCodexHome(options?.sourceCodexHome ?? resolveSourceCodexHome()) };
|
|
859
|
+
})();
|
|
860
|
+
return codexExecutionEnvPromise;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region src/bridge/internal/agents/codex-health.ts
|
|
865
|
+
const CODEX_MODEL$1 = process.env.DEV_COPILOT_CODEX_MODEL ?? "gpt-5.3-codex";
|
|
866
|
+
let codexHealthPromise = null;
|
|
867
|
+
const isAuthenticatedFromStatus = (statusOutput) => {
|
|
868
|
+
const normalized = statusOutput.toLowerCase();
|
|
869
|
+
if (/not logged in|login required|로그인 필요/.test(normalized)) return false;
|
|
870
|
+
return /logged in|로그인됨|로그인되어/.test(normalized);
|
|
871
|
+
};
|
|
872
|
+
const createSchemaSmokePayload = () => JSON.stringify({
|
|
873
|
+
message: "ok",
|
|
874
|
+
warnings: [],
|
|
875
|
+
changes: [{
|
|
876
|
+
path: "src/App.tsx",
|
|
877
|
+
oldText: "before",
|
|
878
|
+
newText: "after"
|
|
879
|
+
}]
|
|
880
|
+
});
|
|
881
|
+
const createPromptJsonSmokePayload = () => JSON.stringify({
|
|
882
|
+
message: "ok",
|
|
883
|
+
warnings: [],
|
|
884
|
+
changes: [{
|
|
885
|
+
path: "src/App.tsx",
|
|
886
|
+
oldText: "before",
|
|
887
|
+
newText: "after"
|
|
888
|
+
}]
|
|
889
|
+
});
|
|
890
|
+
const runExecSmokeTest = async (command, cwd, env) => {
|
|
891
|
+
const outputPath = createTempPath("dev-copilot-codex-answer-smoke", ".txt");
|
|
892
|
+
try {
|
|
893
|
+
await runCli(command, [
|
|
894
|
+
"exec",
|
|
895
|
+
"--ephemeral",
|
|
896
|
+
"--model",
|
|
897
|
+
CODEX_MODEL$1,
|
|
898
|
+
"--cd",
|
|
899
|
+
cwd,
|
|
900
|
+
"--sandbox",
|
|
901
|
+
"read-only",
|
|
902
|
+
"--skip-git-repo-check",
|
|
903
|
+
"--output-last-message",
|
|
904
|
+
outputPath,
|
|
905
|
+
"OK만 출력해줘."
|
|
906
|
+
], {
|
|
907
|
+
cwd,
|
|
908
|
+
timeoutMs: MODEL_STATUS_CHECK_TIMEOUT_MS,
|
|
909
|
+
maxBuffer: 1024 * 512,
|
|
910
|
+
env
|
|
911
|
+
});
|
|
912
|
+
if (!(await readFile(outputPath, "utf-8")).trim()) throw new Error("Codex CLI smoke test output이 비어 있습니다.");
|
|
913
|
+
} finally {
|
|
914
|
+
await rm(outputPath, { force: true });
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
const runEditSchemaSmokeTest = async (command, cwd, env) => {
|
|
918
|
+
const outputPath = createTempPath("dev-copilot-codex-edit-smoke", ".json");
|
|
919
|
+
const schemaPath = createTempPath("dev-copilot-codex-edit-schema", ".json");
|
|
920
|
+
try {
|
|
921
|
+
await writeFile(schemaPath, JSON.stringify(agentEditResponseSchema), "utf-8");
|
|
922
|
+
await runCli(command, [
|
|
923
|
+
"exec",
|
|
924
|
+
"--ephemeral",
|
|
925
|
+
"--model",
|
|
926
|
+
CODEX_MODEL$1,
|
|
927
|
+
"--cd",
|
|
928
|
+
cwd,
|
|
929
|
+
"--sandbox",
|
|
930
|
+
"read-only",
|
|
931
|
+
"--skip-git-repo-check",
|
|
932
|
+
"--output-last-message",
|
|
933
|
+
outputPath,
|
|
934
|
+
"--output-schema",
|
|
935
|
+
schemaPath,
|
|
936
|
+
"message에 ok, warnings에 빈 배열, changes에 한 개의 수정 항목만 담아 JSON으로 응답해줘."
|
|
937
|
+
], {
|
|
938
|
+
cwd,
|
|
939
|
+
timeoutMs: MODEL_STATUS_CHECK_TIMEOUT_MS,
|
|
940
|
+
maxBuffer: 1024 * 512,
|
|
941
|
+
env
|
|
942
|
+
});
|
|
943
|
+
const output = await readFile(outputPath, "utf-8");
|
|
944
|
+
if (output.trim() !== createSchemaSmokePayload()) JSON.parse(output);
|
|
945
|
+
} finally {
|
|
946
|
+
await rm(outputPath, { force: true });
|
|
947
|
+
await rm(schemaPath, { force: true });
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
const runEditPromptJsonSmokeTest = async (command, cwd, env) => {
|
|
951
|
+
const outputPath = createTempPath("dev-copilot-codex-edit-prompt-json-smoke", ".json");
|
|
952
|
+
try {
|
|
953
|
+
await runCli(command, [
|
|
954
|
+
"exec",
|
|
955
|
+
"--ephemeral",
|
|
956
|
+
"--model",
|
|
957
|
+
CODEX_MODEL$1,
|
|
958
|
+
"--cd",
|
|
959
|
+
cwd,
|
|
960
|
+
"--sandbox",
|
|
961
|
+
"read-only",
|
|
962
|
+
"--skip-git-repo-check",
|
|
963
|
+
"--output-last-message",
|
|
964
|
+
outputPath,
|
|
965
|
+
[
|
|
966
|
+
"Return JSON only.",
|
|
967
|
+
"Respond with an object that has message, warnings, and changes.",
|
|
968
|
+
"warnings must be an empty array.",
|
|
969
|
+
"changes must contain exactly one object with path, oldText, and newText.",
|
|
970
|
+
"Use this exact payload:",
|
|
971
|
+
createPromptJsonSmokePayload()
|
|
972
|
+
].join("\n")
|
|
973
|
+
], {
|
|
974
|
+
cwd,
|
|
975
|
+
timeoutMs: MODEL_STATUS_CHECK_TIMEOUT_MS,
|
|
976
|
+
maxBuffer: 1024 * 512,
|
|
977
|
+
env
|
|
978
|
+
});
|
|
979
|
+
const normalized = (await readFile(outputPath, "utf-8")).trim().replace(/^```json\s*|\s*```$/g, "");
|
|
980
|
+
const parsed = JSON.parse(normalized);
|
|
981
|
+
if (JSON.stringify(parsed) !== createPromptJsonSmokePayload()) throw new Error("Codex CLI prompt-json smoke test payload mismatch");
|
|
982
|
+
} finally {
|
|
983
|
+
await rm(outputPath, { force: true });
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
const getCodexHealthCheck = async (cwd) => {
|
|
987
|
+
if (codexHealthPromise) return codexHealthPromise;
|
|
988
|
+
codexHealthPromise = (async () => {
|
|
989
|
+
let command;
|
|
990
|
+
try {
|
|
991
|
+
command = await resolveCodexCommand();
|
|
992
|
+
} catch (error) {
|
|
993
|
+
return {
|
|
994
|
+
status: "unavailable",
|
|
995
|
+
message: error instanceof Error ? error.message : "Codex CLI를 찾을 수 없습니다."
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const env = await getCodexExecutionEnv();
|
|
999
|
+
try {
|
|
1000
|
+
const { stdout, stderr } = await runCli(command, ["login", "status"], {
|
|
1001
|
+
cwd,
|
|
1002
|
+
timeoutMs: STATUS_CHECK_TIMEOUT_MS,
|
|
1003
|
+
maxBuffer: 1024 * 128,
|
|
1004
|
+
env
|
|
1005
|
+
});
|
|
1006
|
+
const statusText = stdout.trim() || stderr.trim();
|
|
1007
|
+
if (statusText && !isAuthenticatedFromStatus(statusText)) return {
|
|
1008
|
+
status: "login_required",
|
|
1009
|
+
message: statusText || "Codex CLI 로그인이 필요합니다.",
|
|
1010
|
+
loginCommand: "codex login",
|
|
1011
|
+
command
|
|
1012
|
+
};
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
const details = extractCliErrorDetails(error);
|
|
1015
|
+
if (isCliTimeout(details)) return {
|
|
1016
|
+
status: "timeout",
|
|
1017
|
+
message: "Codex CLI 상태 확인 중 시간이 초과되었습니다.",
|
|
1018
|
+
command
|
|
1019
|
+
};
|
|
1020
|
+
if (isCliCommandMissing(details)) return {
|
|
1021
|
+
status: "unavailable",
|
|
1022
|
+
message: "Codex CLI를 실행할 수 없습니다."
|
|
1023
|
+
};
|
|
1024
|
+
if (isCliCommandMissing(details)) return {
|
|
1025
|
+
status: "unavailable",
|
|
1026
|
+
message: "Codex CLI를 실행할 수 없습니다."
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
try {
|
|
1030
|
+
await runExecSmokeTest(command, cwd, env);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
const details = extractCliErrorDetails(error);
|
|
1033
|
+
return {
|
|
1034
|
+
status: isCliTimeout(details) ? "timeout" : "exec_failed",
|
|
1035
|
+
message: isCliTimeout(details) ? "Codex CLI 답변 smoke test 중 시간이 초과되었습니다." : "Codex CLI 답변 smoke test에 실패했습니다.",
|
|
1036
|
+
command
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
await runEditSchemaSmokeTest(command, cwd, env);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
if (isCliTimeout(extractCliErrorDetails(error))) return {
|
|
1043
|
+
status: "timeout",
|
|
1044
|
+
message: "Codex CLI edit schema smoke test 중 시간이 초과되었습니다.",
|
|
1045
|
+
command
|
|
1046
|
+
};
|
|
1047
|
+
try {
|
|
1048
|
+
await runEditPromptJsonSmokeTest(command, cwd, env);
|
|
1049
|
+
return {
|
|
1050
|
+
status: "ready",
|
|
1051
|
+
message: "Codex CLI가 prompt-json fallback으로 응답 준비를 마쳤습니다.",
|
|
1052
|
+
command,
|
|
1053
|
+
model: CODEX_MODEL$1,
|
|
1054
|
+
editStrategy: "prompt-json"
|
|
1055
|
+
};
|
|
1056
|
+
} catch {
|
|
1057
|
+
return {
|
|
1058
|
+
status: "schema_failed",
|
|
1059
|
+
message: "Codex CLI edit 응답 스키마 smoke test에 실패했습니다.",
|
|
1060
|
+
command
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
status: "ready",
|
|
1066
|
+
message: "Codex CLI가 응답 준비를 마쳤습니다.",
|
|
1067
|
+
command,
|
|
1068
|
+
model: CODEX_MODEL$1,
|
|
1069
|
+
editStrategy: "output-schema"
|
|
1070
|
+
};
|
|
1071
|
+
})();
|
|
1072
|
+
return codexHealthPromise;
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/bridge/internal/agents/codex-adapter.ts
|
|
1077
|
+
const CODEX_MODEL = process.env.DEV_COPILOT_CODEX_MODEL ?? "gpt-5.3-codex";
|
|
1078
|
+
const CODEX_TIMEOUT_MS = Number(process.env.DEV_COPILOT_CODEX_TIMEOUT_MS ?? DEFAULT_AGENT_TIMEOUT_MS);
|
|
1079
|
+
const isCodexLoginRequiredError = (error) => {
|
|
1080
|
+
const details = extractCliErrorDetails(error);
|
|
1081
|
+
return /not logged in|login required|authentication|unauthorized/i.test(details.merged);
|
|
1082
|
+
};
|
|
1083
|
+
const toCodexErrorMessage = (error) => {
|
|
1084
|
+
const details = extractCliErrorDetails(error);
|
|
1085
|
+
if (/requires a newer version of Codex/i.test(details.merged)) return ["현재 설치된 Codex CLI 버전과 모델 조합이 호환되지 않습니다.", "Codex CLI를 업데이트하거나 DEV_COPILOT_CODEX_MODEL 값을 호환 가능한 모델로 지정해 주세요."].join(" ");
|
|
1086
|
+
if (/migration .* is missing in the resolved migrations/i.test(details.merged)) return ["Codex 로컬 상태 DB 마이그레이션 오류가 발생했습니다.", "Codex CLI를 최신 버전으로 업데이트한 뒤 다시 시도해 주세요."].join(" ");
|
|
1087
|
+
if (/Error loading config\.toml: invalid transport/i.test(details.merged)) return [
|
|
1088
|
+
"현재 Codex CLI가 Codex 설정 파일의 MCP 형식을 해석하지 못했습니다.",
|
|
1089
|
+
"Dev Copilot이 호환되는 Codex 실행 파일을 자동으로 찾지 못했습니다.",
|
|
1090
|
+
"Codex CLI를 업데이트한 뒤 다시 시도해 주세요."
|
|
1091
|
+
].join(" ");
|
|
1092
|
+
if (isCodexLoginRequiredError(error)) return "Codex CLI 로그인이 필요합니다. 터미널에서 `codex login`을 실행해 주세요.";
|
|
1093
|
+
if (isCliCommandMissing(details)) return "Codex CLI를 찾을 수 없습니다. Codex CLI 설치 상태를 확인해 주세요.";
|
|
1094
|
+
return details.message;
|
|
1095
|
+
};
|
|
1096
|
+
const codexAdapter = {
|
|
1097
|
+
agent: "codex",
|
|
1098
|
+
async warmup(cwd) {
|
|
1099
|
+
await getCodexHealthCheck(cwd);
|
|
1100
|
+
},
|
|
1101
|
+
async run(request) {
|
|
1102
|
+
const health = await getCodexHealthCheck(request.cwd);
|
|
1103
|
+
if (health.status !== "ready" || !health.command) throw new Error(health.message);
|
|
1104
|
+
const codexCommand = health.command;
|
|
1105
|
+
const outputPath = createTempPath("dev-copilot-codex", ".json");
|
|
1106
|
+
const schemaPath = createTempPath("dev-copilot-codex-schema", ".json");
|
|
1107
|
+
const useOutputSchema = health.editStrategy !== "prompt-json";
|
|
1108
|
+
if (request.mode === "edit" && useOutputSchema) await promises.writeFile(schemaPath, JSON.stringify(agentEditResponseSchema), "utf-8");
|
|
1109
|
+
const prompt = request.mode === "edit" && !useOutputSchema ? [
|
|
1110
|
+
buildAgentPrompt(request),
|
|
1111
|
+
"",
|
|
1112
|
+
"Return a JSON object only.",
|
|
1113
|
+
"Do not include markdown fences unless strictly necessary.",
|
|
1114
|
+
"The object must contain message, warnings, and changes.",
|
|
1115
|
+
"warnings must be an array of strings.",
|
|
1116
|
+
"changes must be an array of { path, oldText, newText } objects."
|
|
1117
|
+
].join("\n") : buildAgentPrompt(request);
|
|
1118
|
+
const env = await getCodexExecutionEnv();
|
|
1119
|
+
const args = [
|
|
1120
|
+
"exec",
|
|
1121
|
+
"--ephemeral",
|
|
1122
|
+
"--model",
|
|
1123
|
+
CODEX_MODEL,
|
|
1124
|
+
"--cd",
|
|
1125
|
+
request.cwd,
|
|
1126
|
+
"--sandbox",
|
|
1127
|
+
"read-only",
|
|
1128
|
+
"--skip-git-repo-check",
|
|
1129
|
+
"--output-last-message",
|
|
1130
|
+
outputPath,
|
|
1131
|
+
...request.mode === "edit" && useOutputSchema ? ["--output-schema", schemaPath] : [],
|
|
1132
|
+
prompt
|
|
1133
|
+
];
|
|
1134
|
+
try {
|
|
1135
|
+
await runCli(codexCommand, args, {
|
|
1136
|
+
cwd: request.cwd,
|
|
1137
|
+
maxBuffer: DEFAULT_AGENT_MAX_BUFFER_BYTES,
|
|
1138
|
+
timeoutMs: Number(process.env.DEV_COPILOT_AGENT_TIMEOUT_MS ?? CODEX_TIMEOUT_MS),
|
|
1139
|
+
env
|
|
1140
|
+
});
|
|
1141
|
+
if (request.mode === "answer") return parseAnswerResponse(await promises.readFile(outputPath, "utf-8"));
|
|
1142
|
+
return parseCodexEditResponse(await promises.readFile(outputPath, "utf-8"));
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
throw new Error(toCodexErrorMessage(error));
|
|
1145
|
+
} finally {
|
|
1146
|
+
await promises.rm(outputPath, { force: true });
|
|
1147
|
+
await promises.rm(schemaPath, { force: true });
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
async getStatus(cwd) {
|
|
1151
|
+
const health = await getCodexHealthCheck(cwd);
|
|
1152
|
+
if (health.status === "ready") return createAuthenticatedStatus({
|
|
1153
|
+
agent: "codex",
|
|
1154
|
+
message: "Codex CLI에 로그인되어 있습니다.",
|
|
1155
|
+
model: health.model ?? CODEX_MODEL
|
|
1156
|
+
});
|
|
1157
|
+
if (health.status === "login_required") return createLoginRequiredStatus({
|
|
1158
|
+
agent: "codex",
|
|
1159
|
+
message: health.message,
|
|
1160
|
+
loginCommand: health.loginCommand ?? "codex login"
|
|
1161
|
+
});
|
|
1162
|
+
if (health.status === "unavailable") return createUnavailableStatus({
|
|
1163
|
+
agent: "codex",
|
|
1164
|
+
message: health.message
|
|
1165
|
+
});
|
|
1166
|
+
return createUnauthenticatedStatus({
|
|
1167
|
+
agent: "codex",
|
|
1168
|
+
message: health.message
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
//#endregion
|
|
1174
|
+
//#region src/bridge/features/agent-runner/resolve-agent-adapter.ts
|
|
1175
|
+
const adapters = {
|
|
1176
|
+
codex: codexAdapter,
|
|
1177
|
+
claude: claudeAdapter
|
|
1178
|
+
};
|
|
1179
|
+
const testAdapters = {};
|
|
1180
|
+
const resolveAgentAdapter = (agent) => {
|
|
1181
|
+
return testAdapters[agent] ?? adapters[agent];
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/bridge/features/project-context/search-strategies.ts
|
|
1186
|
+
const execFileAsync = promisify(execFile);
|
|
1187
|
+
const normalize = (value) => value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
1188
|
+
const walk = async (rootDir, relativeDir, files, limit) => {
|
|
1189
|
+
if (files.length >= limit) return;
|
|
1190
|
+
const absoluteDir = path.join(rootDir, relativeDir);
|
|
1191
|
+
const entries = await promises.readdir(absoluteDir, { withFileTypes: true });
|
|
1192
|
+
for (const entry of entries) {
|
|
1193
|
+
if (files.length >= limit) return;
|
|
1194
|
+
const relativePath = normalize(path.join(relativeDir, entry.name));
|
|
1195
|
+
if ([
|
|
1196
|
+
"node_modules",
|
|
1197
|
+
".next",
|
|
1198
|
+
".git",
|
|
1199
|
+
"dist",
|
|
1200
|
+
"build",
|
|
1201
|
+
"coverage"
|
|
1202
|
+
].some((name) => relativePath.includes(`/${name}/`) || relativePath.startsWith(`${name}/`))) continue;
|
|
1203
|
+
if (entry.isDirectory()) {
|
|
1204
|
+
await walk(rootDir, relativePath, files, limit);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (entry.isFile()) files.push(relativePath);
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
const searchWithRg = async (config, query, limit) => {
|
|
1211
|
+
const { stdout } = await execFileAsync("rg", [
|
|
1212
|
+
"--line-number",
|
|
1213
|
+
"--fixed-strings",
|
|
1214
|
+
"--color",
|
|
1215
|
+
"never",
|
|
1216
|
+
"--glob",
|
|
1217
|
+
"!node_modules/**",
|
|
1218
|
+
"--glob",
|
|
1219
|
+
"!.next/**",
|
|
1220
|
+
"--glob",
|
|
1221
|
+
"!.git/**",
|
|
1222
|
+
query,
|
|
1223
|
+
...config.allowedDirs
|
|
1224
|
+
], {
|
|
1225
|
+
cwd: config.rootDir,
|
|
1226
|
+
maxBuffer: 1024 * 1024
|
|
1227
|
+
});
|
|
1228
|
+
return stdout.split("\n").filter(Boolean).slice(0, limit).map((line) => {
|
|
1229
|
+
const [filePath, lineNumber, ...rest] = line.split(":");
|
|
1230
|
+
return {
|
|
1231
|
+
path: filePath,
|
|
1232
|
+
line: Number(lineNumber),
|
|
1233
|
+
text: rest.join(":").trim()
|
|
1234
|
+
};
|
|
1235
|
+
});
|
|
1236
|
+
};
|
|
1237
|
+
const searchWithNode = async (config, query, limit) => {
|
|
1238
|
+
const files = [];
|
|
1239
|
+
for (const dir of config.allowedDirs) try {
|
|
1240
|
+
await walk(config.rootDir, dir, files, 500);
|
|
1241
|
+
} catch {
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const results = [];
|
|
1245
|
+
for (const filePath of files) {
|
|
1246
|
+
if (results.length >= limit) break;
|
|
1247
|
+
try {
|
|
1248
|
+
(await promises.readFile(path.join(config.rootDir, filePath), "utf-8")).slice(0, 1e5).split("\n").forEach((line, index) => {
|
|
1249
|
+
if (results.length < limit && line.includes(query)) results.push({
|
|
1250
|
+
path: filePath,
|
|
1251
|
+
line: index + 1,
|
|
1252
|
+
text: line.trim()
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
} catch {
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return results;
|
|
1260
|
+
};
|
|
1261
|
+
const searchProjectText = async (config, query, limit) => {
|
|
1262
|
+
if (!query.trim()) return [];
|
|
1263
|
+
try {
|
|
1264
|
+
return await searchWithRg(config, query, limit);
|
|
1265
|
+
} catch {
|
|
1266
|
+
return searchWithNode(config, query, limit);
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
const compact = (value) => value.replace(/\s+/g, "");
|
|
1270
|
+
const searchProjectTextIgnoringWhitespace = async (config, query, limit) => {
|
|
1271
|
+
const compactQuery = compact(query);
|
|
1272
|
+
if (!compactQuery) return [];
|
|
1273
|
+
const files = [];
|
|
1274
|
+
for (const dir of config.allowedDirs) try {
|
|
1275
|
+
await walk(config.rootDir, dir, files, 500);
|
|
1276
|
+
} catch {
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
const results = [];
|
|
1280
|
+
for (const filePath of files) {
|
|
1281
|
+
if (results.length >= limit) break;
|
|
1282
|
+
try {
|
|
1283
|
+
(await promises.readFile(path.join(config.rootDir, filePath), "utf-8")).slice(0, 1e5).split("\n").forEach((line, index) => {
|
|
1284
|
+
if (results.length < limit && compact(line).includes(compactQuery)) results.push({
|
|
1285
|
+
path: filePath,
|
|
1286
|
+
line: index + 1,
|
|
1287
|
+
text: line.trim()
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
} catch {
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return results;
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region src/bridge/features/project-context/build-project-context.ts
|
|
1299
|
+
const findBySelectedText = async (rootDir, allowedDirs, text) => {
|
|
1300
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
1301
|
+
const candidates = [
|
|
1302
|
+
normalized,
|
|
1303
|
+
normalized.slice(0, 120),
|
|
1304
|
+
...normalized.split(/[.!?]\s+/).filter((item) => item.length > 12)
|
|
1305
|
+
];
|
|
1306
|
+
for (const candidate of candidates) {
|
|
1307
|
+
const results = await searchProjectText({
|
|
1308
|
+
rootDir,
|
|
1309
|
+
allowedDirs
|
|
1310
|
+
}, candidate, 8);
|
|
1311
|
+
if (results.length) return {
|
|
1312
|
+
query: candidate,
|
|
1313
|
+
results
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
for (const candidate of candidates) {
|
|
1317
|
+
const results = await searchProjectTextIgnoringWhitespace({
|
|
1318
|
+
rootDir,
|
|
1319
|
+
allowedDirs
|
|
1320
|
+
}, candidate, 8);
|
|
1321
|
+
if (results.length) return {
|
|
1322
|
+
query: `${candidate} (ignore-whitespace)`,
|
|
1323
|
+
results
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
return {
|
|
1327
|
+
query: normalized,
|
|
1328
|
+
results: []
|
|
1329
|
+
};
|
|
1330
|
+
};
|
|
1331
|
+
const buildCopilotProjectContext = async (rootDir, allowedDirs, params) => {
|
|
1332
|
+
const routeQuery = params.route?.startsWith("/") ? `app${params.route === "/" ? "" : params.route}/page.tsx` : params.route;
|
|
1333
|
+
const selectedText = params.selectedText?.trim();
|
|
1334
|
+
const textMatches = selectedText ? await findBySelectedText(rootDir, allowedDirs, selectedText) : {
|
|
1335
|
+
query: "",
|
|
1336
|
+
results: []
|
|
1337
|
+
};
|
|
1338
|
+
const routeMatches = routeQuery ? await searchProjectText({
|
|
1339
|
+
rootDir,
|
|
1340
|
+
allowedDirs
|
|
1341
|
+
}, routeQuery, 5) : [];
|
|
1342
|
+
return JSON.stringify({
|
|
1343
|
+
project: { allowedDirs },
|
|
1344
|
+
requestContext: {
|
|
1345
|
+
route: params.route,
|
|
1346
|
+
fileHints: params.fileHints
|
|
1347
|
+
},
|
|
1348
|
+
selectedTextLookup: textMatches,
|
|
1349
|
+
routeLookup: routeMatches,
|
|
1350
|
+
guidance: "selectedTextLookup.results의 path와 text를 우선 사용해 path/oldText/newText 수정안을 작성하세요."
|
|
1351
|
+
}, null, 2);
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
//#endregion
|
|
1355
|
+
//#region src/bridge/features/chat/chat-route.ts
|
|
1356
|
+
const isCopilotAgent$1 = (value) => {
|
|
1357
|
+
return value === "codex" || value === "claude";
|
|
1358
|
+
};
|
|
1359
|
+
const handleChatRoute = async (request, response, config) => {
|
|
1360
|
+
const payload = await readJsonBody(request);
|
|
1361
|
+
const effectiveAllowedPaths = resolveAllowedPathsWithSrcFallback(config, payload.context?.fileHints);
|
|
1362
|
+
const adapter = resolveAgentAdapter(isCopilotAgent$1(payload.context?.agent) ? payload.context.agent : config.agent);
|
|
1363
|
+
if (!payload.prompt?.trim()) {
|
|
1364
|
+
sendJson(response, config, 400, { error: "프롬프트를 입력해 주세요." });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const projectContext = await buildCopilotProjectContext(config.rootDir, effectiveAllowedPaths, {
|
|
1368
|
+
selectedText: payload.selectedText,
|
|
1369
|
+
route: payload.context?.route,
|
|
1370
|
+
fileHints: payload.context?.fileHints
|
|
1371
|
+
});
|
|
1372
|
+
const agentResponse = await adapter.run({
|
|
1373
|
+
selectedText: payload.selectedText ?? "",
|
|
1374
|
+
prompt: payload.prompt,
|
|
1375
|
+
mode: payload.mode,
|
|
1376
|
+
route: payload.context?.route,
|
|
1377
|
+
fileHints: payload.context?.fileHints,
|
|
1378
|
+
previousResponse: payload.context?.previousResponse,
|
|
1379
|
+
projectContext,
|
|
1380
|
+
cwd: config.rootDir
|
|
1381
|
+
});
|
|
1382
|
+
if (payload.mode === "answer") {
|
|
1383
|
+
sendJson(response, config, 200, {
|
|
1384
|
+
message: agentResponse.message,
|
|
1385
|
+
warnings: agentResponse.warnings
|
|
1386
|
+
});
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
let patchPreview = "";
|
|
1390
|
+
try {
|
|
1391
|
+
patchPreview = await buildPatchPreview(agentResponse.changes, config, effectiveAllowedPaths);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
sendJson(response, config, 200, {
|
|
1394
|
+
message: agentResponse.message ?? "에이전트가 수정안을 만들었지만 실제 파일 내용과 매칭하지 못했습니다.",
|
|
1395
|
+
warnings: [...agentResponse.warnings ?? [], error instanceof Error ? error.message : "수정안 생성에 실패했습니다."]
|
|
1396
|
+
});
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
if (!patchPreview) {
|
|
1400
|
+
sendJson(response, config, 200, {
|
|
1401
|
+
message: agentResponse.message ?? "패치 제안을 생성하지 못했습니다.",
|
|
1402
|
+
warnings: [...agentResponse.warnings ?? [], "적용 가능한 변경 목록이 없어 패치 미리보기와 적용 버튼을 만들 수 없습니다."]
|
|
1403
|
+
});
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
try {
|
|
1407
|
+
await validatePatchPreview(patchPreview, config, effectiveAllowedPaths);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
sendJson(response, config, 200, {
|
|
1410
|
+
message: agentResponse.message ?? "에이전트가 수정안을 만들었지만 적용 가능한 diff 형식이 아닙니다.",
|
|
1411
|
+
patchPreview,
|
|
1412
|
+
warnings: [...agentResponse.warnings ?? [], error instanceof Error ? error.message : "patch 검증에 실패했습니다."]
|
|
1413
|
+
});
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const patch = saveProposedPatch(patchPreview, effectiveAllowedPaths);
|
|
1417
|
+
sendJson(response, config, 200, {
|
|
1418
|
+
message: agentResponse.message ?? "에이전트가 패치 미리보기를 생성했습니다.",
|
|
1419
|
+
patchPreview,
|
|
1420
|
+
patchId: patch.patchId,
|
|
1421
|
+
warnings: agentResponse.warnings ?? []
|
|
1422
|
+
});
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
//#endregion
|
|
1426
|
+
//#region src/bridge/features/status/status-route.ts
|
|
1427
|
+
const isCopilotAgent = (value) => {
|
|
1428
|
+
return value === "codex" || value === "claude";
|
|
1429
|
+
};
|
|
1430
|
+
const handleStatusRoute = async (_request, response, config, url) => {
|
|
1431
|
+
const queryAgent = url.searchParams.get("agent");
|
|
1432
|
+
sendJson(response, config, 200, await resolveAgentAdapter(isCopilotAgent(queryAgent) ? queryAgent : config.agent).getStatus(config.rootDir));
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/bridge/shared/http/error-mapper.ts
|
|
1437
|
+
const toBridgeErrorMessage = (error) => {
|
|
1438
|
+
if (error instanceof Error) return error.message;
|
|
1439
|
+
return "브리지 서버 처리 중 오류가 발생했습니다.";
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region src/bridge/app/create-bridge-server.ts
|
|
1444
|
+
const createDevCopilotBridgeServer = (config) => {
|
|
1445
|
+
return createServer(async (request, response) => {
|
|
1446
|
+
const method = request.method ?? "GET";
|
|
1447
|
+
const url = new URL(request.url ?? "/", `http://${config.host}:${config.port}`);
|
|
1448
|
+
if (method === "OPTIONS") {
|
|
1449
|
+
response.writeHead(204, createCorsHeaders(config));
|
|
1450
|
+
response.end();
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
try {
|
|
1454
|
+
if (method === "GET" && url.pathname === "/status") {
|
|
1455
|
+
await handleStatusRoute(request, response, config, url);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
if (method === "POST" && url.pathname === "/chat") {
|
|
1459
|
+
await handleChatRoute(request, response, config);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (method === "POST" && url.pathname === "/apply") {
|
|
1463
|
+
await handleApplyRoute(request, response, config);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
sendJson(response, config, 404, { error: "지원하지 않는 경로입니다." });
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
sendJson(response, config, 500, { error: toBridgeErrorMessage(error) });
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/bridge/app/run-bridge-cli.ts
|
|
1475
|
+
const resolveAgent = (value) => {
|
|
1476
|
+
if (value === "claude") return "claude";
|
|
1477
|
+
return "codex";
|
|
1478
|
+
};
|
|
1479
|
+
const runDevCopilotBridgeCli = async (argv) => {
|
|
1480
|
+
const portFlagIndex = argv.findIndex((value) => value === "-p");
|
|
1481
|
+
const portFlagValue = portFlagIndex >= 0 ? argv[portFlagIndex + 1] : void 0;
|
|
1482
|
+
const positionalPort = argv.find((value) => /^\d+$/.test(value));
|
|
1483
|
+
const resolvedPort = portFlagValue ? Number(portFlagValue) : positionalPort ? Number(positionalPort) : Number(process.env.DEV_COPILOT_BRIDGE_PORT ?? 3339);
|
|
1484
|
+
const positionalAgent = argv.find((value) => value === "codex" || value === "claude");
|
|
1485
|
+
const config = createDevCopilotBridgeConfig({
|
|
1486
|
+
rootDir: process.cwd(),
|
|
1487
|
+
host: process.env.DEV_COPILOT_BRIDGE_HOST,
|
|
1488
|
+
port: Number.isFinite(resolvedPort) ? resolvedPort : 3339,
|
|
1489
|
+
corsOrigin: process.env.DEV_COPILOT_BRIDGE_CORS_ORIGIN ?? "*",
|
|
1490
|
+
agent: resolveAgent(positionalAgent),
|
|
1491
|
+
allowedPaths: (process.env.DEV_COPILOT_ALLOWED_PATHS ?? "app,src,widgets,features,entities,shared,components").split(",").map((value) => value.trim()).filter(Boolean)
|
|
1492
|
+
});
|
|
1493
|
+
const server = createDevCopilotBridgeServer(config);
|
|
1494
|
+
await new Promise((resolve, reject) => {
|
|
1495
|
+
server.once("error", (error) => {
|
|
1496
|
+
reject(error);
|
|
1497
|
+
});
|
|
1498
|
+
server.listen(config.port, config.host, () => {
|
|
1499
|
+
process.stdout.write(`[dev-copilot-bridge] listening on http://${config.host}:${config.port}\n`);
|
|
1500
|
+
resolve();
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
resolveAgentAdapter(config.agent).warmup?.(config.rootDir).catch((error) => {
|
|
1504
|
+
process.stderr.write(`[dev-copilot-bridge] startup health check failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1505
|
+
});
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
//#endregion
|
|
1509
|
+
export { createDevCopilotBridgeServer as n, createDevCopilotBridgeConfig as r, runDevCopilotBridgeCli as t };
|
|
1510
|
+
//# sourceMappingURL=run-bridge-cli-DVLGcVgq.mjs.map
|