package shine.agent.botcoder.codex; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class CodexClient { private static final Logger log = LoggerFactory.getLogger(CodexClient.class); private final Path codexBin; private final Path codexWorkDir; private final int timeoutSeconds; private final AtomicReference activeProcess = new AtomicReference<>(); public CodexClient(Path codexBin, Path codexWorkDir, int timeoutSeconds) { this.codexBin = codexBin; this.codexWorkDir = codexWorkDir; this.timeoutSeconds = timeoutSeconds; } public String executePrompt(String prompt, CodexStatusListener statusListener) throws IOException, InterruptedException { Path lastMessageFile = Files.createTempFile("shine-codex-last-message-", ".txt"); List command = new ArrayList<>(); command.add(codexBin.toString()); command.add("exec"); command.add("--dangerously-bypass-approvals-and-sandbox"); command.add("--json"); command.add("-C"); command.add(codexWorkDir.toString()); command.add("-o"); command.add(lastMessageFile.toString()); command.add(prompt); log.info("Запуск codex exec, bin={}, workdir={}", codexBin, codexWorkDir); ProcessBuilder builder = new ProcessBuilder(command); builder.redirectErrorStream(true); Process process = builder.start(); activeProcess.set(process); if (statusListener != null) { statusListener.onStatus("Codex запущен"); } StringBuilder output = new StringBuilder(); Thread outputThread = new Thread(() -> readOutput(process, output, statusListener)); outputThread.setDaemon(true); outputThread.start(); boolean finished; try { finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); } catch (InterruptedException interrupted) { process.destroyForcibly(); joinOutputThread(outputThread); activeProcess.compareAndSet(process, null); Thread.currentThread().interrupt(); throw interrupted; } try { if (!finished) { process.destroyForcibly(); joinOutputThread(outputThread); log.error("Codex timeout after {}s", timeoutSeconds); throw new IOException("Codex timeout after " + timeoutSeconds + "s"); } joinOutputThread(outputThread); int exitCode = process.exitValue(); String lastMessage = ""; if (Files.exists(lastMessageFile)) { lastMessage = Files.readString(lastMessageFile, StandardCharsets.UTF_8).trim(); } if (exitCode != 0) { log.error("Codex exit code={}, outputTail={}", exitCode, tail(output.toString(), 500)); throw new IOException("Codex exited with code " + exitCode + ". Output: " + tail(output.toString(), 1800)); } if (!lastMessage.isBlank()) { return lastMessage; } String fallback = extractFallbackMessage(output.toString()); if (fallback.isBlank()) { throw new IOException("Codex returned empty response"); } return fallback; } finally { activeProcess.compareAndSet(process, null); try { Files.deleteIfExists(lastMessageFile); } catch (IOException ignored) { } } } public boolean stopActiveProcess() { Process process = activeProcess.getAndSet(null); if (process == null) { return false; } process.destroy(); try { if (!process.waitFor(2, TimeUnit.SECONDS)) { process.destroyForcibly(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); process.destroyForcibly(); } return true; } private void readOutput(Process process, StringBuilder output, CodexStatusListener statusListener) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append('\n'); String status = normalizeStatusLine(line); if (status != null && statusListener != null) { statusListener.onStatus(status); } } } catch (Exception ignored) { } } private String normalizeStatusLine(String line) { String trimmed = line == null ? "" : line.trim(); if (trimmed.isEmpty()) { return null; } if (trimmed.contains("\"type\":\"thread.started\"")) { return "Codex: инициализировал сессию"; } if (trimmed.contains("\"type\":\"turn.started\"")) { return "Codex: начал обработку запроса"; } if (trimmed.contains("\"type\":\"item.completed\"") && trimmed.contains("\"type\":\"agent_message\"")) { return "Codex: формирует финальный ответ"; } if (trimmed.contains("\"type\":\"turn.completed\"")) { return "Codex: завершил шаг"; } if (trimmed.contains("\"type\":\"error\"")) { return "Codex: ошибка выполнения"; } if (trimmed.contains("\"type\":\"reasoning\"")) { return "Codex: анализирует задачу"; } if (trimmed.contains("\"type\":\"function_call\"")) { return "Codex: вызывает инструмент"; } if (trimmed.contains("\"type\":\"function_call_output\"")) { return "Codex: получил результат инструмента"; } if (trimmed.contains("\"type\":\"message\"") && trimmed.contains("\"role\":\"assistant\"")) { return "Codex: формирует ответ"; } if (trimmed.startsWith("mcp")) { return "Codex: инициализирует MCP"; } if (trimmed.startsWith("tokens used")) { return "Codex: завершает обработку"; } if (trimmed.startsWith("ERROR:")) { return "Codex: ошибка выполнения"; } return null; } private void joinOutputThread(Thread outputThread) throws InterruptedException { try { outputThread.join(Duration.ofSeconds(2).toMillis()); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw interrupted; } } private String extractFallbackMessage(String rawOutput) { String[] lines = rawOutput.split("\\R"); for (int i = lines.length - 1; i >= 0; i--) { String line = lines[i].trim(); if (line.isEmpty()) { continue; } if (line.startsWith("tokens used")) { continue; } if (line.startsWith("OpenAI Codex")) { continue; } if (line.startsWith("workdir:") || line.startsWith("model:") || line.startsWith("provider:")) { continue; } if (line.startsWith("approval:") || line.startsWith("sandbox:") || line.startsWith("reasoning")) { continue; } if (line.equals("user") || line.equals("exec") || line.equals("--------")) { continue; } return line; } return ""; } private String tail(String value, int maxLen) { if (value.length() <= maxLen) { return value; } return value.substring(value.length() - maxLen); } }