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 | 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 => { 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((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) }; };