247 lines
6.8 KiB
TypeScript
247 lines
6.8 KiB
TypeScript
import { spawn } from "child_process";
|
||
import { promises as fs } from "fs";
|
||
import path from "path";
|
||
|
||
export type CodexSandbox = "read-only" | "workspace-write";
|
||
|
||
export type CodexExecJsonLine = {
|
||
type: string;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
[key: string]: any;
|
||
};
|
||
|
||
const getCodexBin = () => (process.platform === "win32" ? "codex.cmd" : "codex");
|
||
|
||
const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
|
||
|
||
const safeKill = (child: ReturnType<typeof spawn> | null | undefined) => {
|
||
if (!child) return;
|
||
try {
|
||
if (child.exitCode !== null) return;
|
||
child.kill();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 从 startDir 向上查找 workspace 根目录。
|
||
* - 优先以 `AGENTS.md` 作为锚点(本仓库协作约定)
|
||
* - 若找不到,则退化为 `startDir`
|
||
*/
|
||
export const findWorkspaceRoot = async (startDir: string): Promise<string> => {
|
||
let dir = path.resolve(startDir || process.cwd());
|
||
for (let i = 0; i < 12; i += 1) {
|
||
const agents = path.join(dir, "AGENTS.md");
|
||
try {
|
||
const st = await fs.stat(agents);
|
||
if (st.isFile()) return dir;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
return path.resolve(startDir || process.cwd());
|
||
};
|
||
|
||
const buildCodexPromptFromMessages = (
|
||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>,
|
||
): string => {
|
||
const lines: string[] = [];
|
||
for (const m of messages) {
|
||
const role = m.role === "system" ? "系统" : m.role === "user" ? "用户" : "助手";
|
||
lines.push(`【${role}】`);
|
||
lines.push(String(m.content ?? "").trimEnd());
|
||
lines.push("");
|
||
}
|
||
return lines.join("\n").trim();
|
||
};
|
||
|
||
export const codexExecToLastMessage = async ({
|
||
cwd,
|
||
sandbox,
|
||
prompt,
|
||
model,
|
||
}: {
|
||
cwd: string;
|
||
sandbox: CodexSandbox;
|
||
prompt: string;
|
||
model?: string | null;
|
||
}): Promise<{ text: string; rawLines: CodexExecJsonLine[] }> => {
|
||
const bin = getCodexBin();
|
||
// 用 stdin 传入 prompt,避免 Windows 命令行长度限制
|
||
const args = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
|
||
if (isNonEmptyString(model)) args.push("-m", model.trim());
|
||
args.push("-");
|
||
|
||
const rawLines: CodexExecJsonLine[] = [];
|
||
let lastAgentText = "";
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const child = spawn(bin, args, {
|
||
windowsHide: true,
|
||
env: process.env,
|
||
});
|
||
|
||
try {
|
||
child.stdin?.setDefaultEncoding("utf8");
|
||
child.stdin?.write(String(prompt ?? ""));
|
||
child.stdin?.end();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
let buf = "";
|
||
const onData = (chunk: Buffer) => {
|
||
buf += chunk.toString("utf8");
|
||
while (true) {
|
||
const nl = buf.indexOf("\n");
|
||
if (nl === -1) break;
|
||
const line = buf.slice(0, nl).trim();
|
||
buf = buf.slice(nl + 1);
|
||
if (!line) continue;
|
||
try {
|
||
const j = JSON.parse(line) as CodexExecJsonLine;
|
||
rawLines.push(j);
|
||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||
lastAgentText = String(j.item.text ?? "");
|
||
}
|
||
} catch {
|
||
// ignore:非 JSON 行
|
||
}
|
||
}
|
||
};
|
||
|
||
child.stdout?.on("data", onData);
|
||
child.stderr?.on("data", () => {
|
||
// ignore:codex --json 主要走 stdout
|
||
});
|
||
child.on("error", reject);
|
||
child.on("close", (code) => {
|
||
if (buf.trim()) {
|
||
try {
|
||
const j = JSON.parse(buf.trim()) as CodexExecJsonLine;
|
||
rawLines.push(j);
|
||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||
lastAgentText = String(j.item.text ?? "");
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
if (code && code !== 0) {
|
||
reject(new Error(`Codex 执行失败:exit=${code}`));
|
||
return;
|
||
}
|
||
resolve();
|
||
});
|
||
});
|
||
|
||
return { text: lastAgentText.trim() ? lastAgentText.trim() : "(无输出)", rawLines };
|
||
};
|
||
|
||
export const codexMessagesToPrompt = buildCodexPromptFromMessages;
|
||
|
||
export const startCodexJsonRun = ({
|
||
cwd,
|
||
sandbox,
|
||
prompt,
|
||
model,
|
||
sessionId,
|
||
onJsonLine,
|
||
}: {
|
||
cwd: string;
|
||
sandbox: CodexSandbox;
|
||
prompt: string;
|
||
model?: string | null;
|
||
sessionId?: string | null;
|
||
onJsonLine?: (line: CodexExecJsonLine) => void;
|
||
}) => {
|
||
const bin = getCodexBin();
|
||
|
||
const args = (() => {
|
||
// 新会话:可指定 -C / -s
|
||
if (!isNonEmptyString(sessionId)) {
|
||
const a = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
|
||
if (isNonEmptyString(model)) a.push("-m", model.trim());
|
||
a.push("-"); // stdin prompt
|
||
return a;
|
||
}
|
||
|
||
// 续聊:用 sessionId 恢复;resume 子命令不支持 -C/-s(沿用会话配置)
|
||
const a = ["exec", "resume", sessionId.trim(), "--json"];
|
||
if (isNonEmptyString(model)) a.push("-m", model.trim());
|
||
a.push("-"); // stdin prompt
|
||
return a;
|
||
})();
|
||
|
||
const child = spawn(bin, args, {
|
||
cwd,
|
||
windowsHide: true,
|
||
env: process.env,
|
||
});
|
||
|
||
try {
|
||
child.stdin?.setDefaultEncoding("utf8");
|
||
child.stdin?.write(String(prompt ?? ""));
|
||
child.stdin?.end();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
let threadId = "";
|
||
let lastAgentText = "";
|
||
|
||
let buf = "";
|
||
const parseLine = (line: string) => {
|
||
const s = String(line ?? "").trim();
|
||
if (!s) return;
|
||
try {
|
||
const j = JSON.parse(s) as CodexExecJsonLine;
|
||
onJsonLine?.(j);
|
||
if (j.type === "thread.started" && isNonEmptyString(j.thread_id)) {
|
||
threadId = j.thread_id.trim();
|
||
}
|
||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||
lastAgentText = String(j.item.text ?? "");
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
child.stdout?.on("data", (chunk: Buffer) => {
|
||
buf += chunk.toString("utf8");
|
||
while (true) {
|
||
const nl = buf.indexOf("\n");
|
||
if (nl === -1) break;
|
||
const line = buf.slice(0, nl);
|
||
buf = buf.slice(nl + 1);
|
||
parseLine(line);
|
||
}
|
||
});
|
||
|
||
child.stderr?.on("data", () => {
|
||
// ignore:--json 主要走 stdout
|
||
});
|
||
|
||
const done = new Promise<{ ok: true; threadId: string; text: string } | { ok: false; error: string; threadId: string; text: string }>((resolve) => {
|
||
child.on("close", (code) => {
|
||
if (buf.trim()) parseLine(buf);
|
||
const text = lastAgentText.trim() ? lastAgentText.trim() : "(无输出)";
|
||
if (code && code !== 0) {
|
||
resolve({ ok: false, error: `Codex 执行失败:exit=${code}`, threadId, text });
|
||
return;
|
||
}
|
||
resolve({ ok: true, threadId, text });
|
||
});
|
||
child.on("error", (e) => {
|
||
resolve({ ok: false, error: e instanceof Error ? e.message : String(e), threadId, text: lastAgentText.trim() || "(无输出)" });
|
||
});
|
||
});
|
||
|
||
return { child, done, kill: () => safeKill(child) };
|
||
};
|