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) };
|
|||
|
|
};
|