419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
|
|
"use strict";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Codex backend driver — wraps @openai/codex-sdk.
|
|||
|
|
*
|
|||
|
|
* new Codex({ codexPathOverride, env, apiKey, config }).startThread({...}).runStreamed(...)
|
|||
|
|
* - sandbox:'read-only' blocks local writes; side effects must go through the
|
|||
|
|
* injected netcatty MCP server (config.mcp_servers).
|
|||
|
|
* - thread.id is the resumable session id; codex.resumeThread(id) continues it.
|
|||
|
|
*
|
|||
|
|
* Constructor/event field names are calibrated against @openai/codex-sdk's type
|
|||
|
|
* defs (CodexOptions.codexPathOverride; AgentMessageItem / CommandExecutionItem /
|
|||
|
|
* McpToolCallItem). `env` is also passed so the binary resolves on PATH. Live
|
|||
|
|
* smoke confirms end-to-end behavior.
|
|||
|
|
*/
|
|||
|
|
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
|||
|
|
|
|||
|
|
function isImageAttachment(attachment) {
|
|||
|
|
return Boolean(
|
|||
|
|
attachment &&
|
|||
|
|
typeof attachment.filePath === "string" &&
|
|||
|
|
attachment.filePath.length > 0 &&
|
|||
|
|
String(attachment.mediaType || "").toLowerCase().startsWith("image/"),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildCodexPromptInput(prompt, attachments) {
|
|||
|
|
const imageAttachments = Array.isArray(attachments)
|
|||
|
|
? attachments.filter(isImageAttachment)
|
|||
|
|
: [];
|
|||
|
|
if (imageAttachments.length === 0) return String(prompt || "");
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
{ type: "text", text: String(prompt || "") },
|
|||
|
|
...imageAttachments.map((attachment) => ({
|
|||
|
|
type: "local_image",
|
|||
|
|
path: attachment.filePath,
|
|||
|
|
})),
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toCodexMcpConfig(injectedMcpServers, { defaultToolsApprovalMode } = {}) {
|
|||
|
|
const mcp_servers = {};
|
|||
|
|
for (const cfg of injectedMcpServers || []) {
|
|||
|
|
if (!cfg || !cfg.name) continue;
|
|||
|
|
mcp_servers[cfg.name] = {
|
|||
|
|
command: cfg.command,
|
|||
|
|
args: cfg.args || [],
|
|||
|
|
env: mcpEnvPairsToObject(cfg.env),
|
|||
|
|
...(defaultToolsApprovalMode
|
|||
|
|
? { default_tools_approval_mode: defaultToolsApprovalMode }
|
|||
|
|
: {}),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return mcp_servers;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildCodexConstructorOptions({ codexPath, env, apiKey, injectedMcpServers, baseUrl }) {
|
|||
|
|
const options = {
|
|||
|
|
env,
|
|||
|
|
config: {
|
|||
|
|
mcp_servers: toCodexMcpConfig(injectedMcpServers),
|
|||
|
|
// Force codex to emit reasoning SUMMARY items in the JSON stream. The
|
|||
|
|
// default ("auto") emits nothing in non-interactive `codex exec` (measured:
|
|||
|
|
// 0 summaries across runs), so the thinking panel went empty after the SDK
|
|||
|
|
// migration. "concise" restores visible step-by-step reasoning reliably
|
|||
|
|
// (measured: a summary on every reasoning turn) at the right altitude for a
|
|||
|
|
// terminal assistant — "detailed" is richer but noisier and less reliable.
|
|||
|
|
model_reasoning_summary: "concise",
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
if (codexPath) options.codexPathOverride = codexPath; // 🔬 SMOKE-CALIBRATE [codex-path]
|
|||
|
|
if (apiKey) options.apiKey = apiKey;
|
|||
|
|
if (baseUrl) options.baseUrl = baseUrl;
|
|||
|
|
return options;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// codex-sdk reasoning-effort levels (GPT-5.6 also advertises max/ultra).
|
|||
|
|
const CODEX_REASONING_EFFORTS = new Set([
|
|||
|
|
"minimal",
|
|||
|
|
"low",
|
|||
|
|
"medium",
|
|||
|
|
"high",
|
|||
|
|
"xhigh",
|
|||
|
|
"max",
|
|||
|
|
"ultra",
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
function parseCodexModelSelection(model) {
|
|||
|
|
const value = String(model || "");
|
|||
|
|
const slash = value.lastIndexOf("/");
|
|||
|
|
const effort = slash > 0 ? value.slice(slash + 1) : "";
|
|||
|
|
if (slash > 0 && CODEX_REASONING_EFFORTS.has(effort)) {
|
|||
|
|
return { model: value.slice(0, slash), effort };
|
|||
|
|
}
|
|||
|
|
return { model: value || undefined, effort: undefined };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildCodexThreadOptions({ cwd, model }) {
|
|||
|
|
// model + sandboxMode + workingDirectory belong to ThreadOptions (startThread).
|
|||
|
|
// runStreamed's TurnOptions only accepts { outputSchema, signal }, so passing
|
|||
|
|
// them there (the previous behavior) silently dropped both model selection and
|
|||
|
|
// the read-only sandbox.
|
|||
|
|
//
|
|||
|
|
// Non-interactive `codex exec` CANCELS every MCP tool call ("user cancelled
|
|||
|
|
// MCP tool call", failing in 0ns before the server is even invoked) unless
|
|||
|
|
// approvals are fully bypassed. Empirically (tested across all sandbox ×
|
|||
|
|
// approval combos) the ONLY combo that lets injected netcatty MCP tools run is
|
|||
|
|
// sandbox "danger-full-access" + approvalPolicy "never" — i.e. codex's
|
|||
|
|
// `--dangerously-bypass-approvals-and-sandbox`. read-only and workspace-write
|
|||
|
|
// both cancel under every approval policy, because codex wants an interactive
|
|||
|
|
// approver for MCP calls and exec has no channel to answer one.
|
|||
|
|
//
|
|||
|
|
// Safe for netcatty's model: the REAL guardrails (approval prompts, command
|
|||
|
|
// blocklist, observer/confirm permission modes, session scope) are enforced by
|
|||
|
|
// the injected netcatty MCP server on every remote-host action — NOT by codex's
|
|||
|
|
// local sandbox. claude blocks its built-in side-effect tools via
|
|||
|
|
// disallowedTools and copilot is MCP-only; codex-sdk exposes no tool-disable
|
|||
|
|
// switch, so the sandbox is the only lever and it has to be fully open for the
|
|||
|
|
// MCP path to work at all.
|
|||
|
|
const opts = { sandboxMode: "danger-full-access", approvalPolicy: "never", skipGitRepoCheck: true };
|
|||
|
|
if (cwd) opts.workingDirectory = cwd;
|
|||
|
|
if (model) {
|
|||
|
|
// The renderer encodes codex reasoning effort as "<modelId>/<effort>"
|
|||
|
|
// (e.g. "gpt-5.5/high"). codex-sdk wants them as separate ThreadOptions.
|
|||
|
|
// Only split when the trailing segment is a real effort — custom/OpenRouter
|
|||
|
|
// model ids may legitimately contain "/".
|
|||
|
|
const selection = parseCodexModelSelection(model);
|
|||
|
|
opts.model = selection.model;
|
|||
|
|
if (selection.effort) opts.modelReasoningEffort = selection.effort;
|
|||
|
|
}
|
|||
|
|
return opts;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Extract a display string from a Codex mcp_tool_call item.
|
|||
|
|
* Calibrated against @openai/codex-sdk McpToolCallItem: successful calls carry
|
|||
|
|
* `result.content` as an MCP ContentBlock[] (text blocks); failures carry
|
|||
|
|
* `error.message`.
|
|||
|
|
*/
|
|||
|
|
function extractMcpResultText(item) {
|
|||
|
|
if (item.error && item.error.message) return String(item.error.message);
|
|||
|
|
const content = item.result && item.result.content;
|
|||
|
|
if (Array.isArray(content)) {
|
|||
|
|
return content
|
|||
|
|
.map((b) => (b && typeof b.text === "string" ? b.text : (b == null ? "" : JSON.stringify(b))))
|
|||
|
|
.join("");
|
|||
|
|
}
|
|||
|
|
if (item.result != null) return JSON.stringify(item.result);
|
|||
|
|
return "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureStateSet(state, key) {
|
|||
|
|
if (!state[key]) state[key] = new Set();
|
|||
|
|
return state[key];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureStateMap(state, key) {
|
|||
|
|
if (!state[key]) state[key] = new Map();
|
|||
|
|
return state[key];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function emitCodexReasoning(item, emitter, state) {
|
|||
|
|
if (!item || typeof item.text !== "string" || !item.text) return;
|
|||
|
|
const textById = ensureStateMap(state, "reasoningTextById");
|
|||
|
|
const itemId = item.id || "__default_reasoning";
|
|||
|
|
const previous = textById.get(itemId) || "";
|
|||
|
|
const delta = item.text.startsWith(previous) ? item.text.slice(previous.length) : item.text;
|
|||
|
|
textById.set(itemId, item.text);
|
|||
|
|
if (delta) {
|
|||
|
|
emitter.reasoning(delta);
|
|||
|
|
state.reasoningOpen = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function emitCodexToolCallOnce(item, emitter, state, toolName, args) {
|
|||
|
|
if (!item || !item.id) return false;
|
|||
|
|
const emittedToolCalls = ensureStateSet(state, "emittedToolCalls");
|
|||
|
|
if (emittedToolCalls.has(item.id)) return false;
|
|||
|
|
emittedToolCalls.add(item.id);
|
|||
|
|
emitter.toolCall(toolName, args || {}, item.id);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function emitCodexToolResultOnce(item, emitter, state, output, toolName) {
|
|||
|
|
if (!item || !item.id) return false;
|
|||
|
|
const emittedToolResults = ensureStateSet(state, "emittedToolResults");
|
|||
|
|
if (emittedToolResults.has(item.id)) return false;
|
|||
|
|
emittedToolResults.add(item.id);
|
|||
|
|
emitter.toolResult(item.id, output || "", toolName);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Codex emits mid-turn `type:"error"` JSONL events while it reconnects after a
|
|||
|
|
* dropped SSE/response body (`Reconnecting...`, `retrying N/M`). Those are
|
|||
|
|
* recoverable — the same turn keeps producing items afterward. Treating them
|
|||
|
|
* as fatal settles the Netcatty sidebar turn and stops UI refresh while the CLI
|
|||
|
|
* process continues (issue #2456).
|
|||
|
|
*
|
|||
|
|
* Explicit `willRetry: false` / `will_retry: false` means Codex exhausted its
|
|||
|
|
* retry budget — always fatal, even when the message still mentions stream /
|
|||
|
|
* transport wording. Truly terminal failures also arrive as `turn.failed`.
|
|||
|
|
*/
|
|||
|
|
function isCodexRetryableStreamError(event) {
|
|||
|
|
if (!event || typeof event !== "object") return false;
|
|||
|
|
if (event.willRetry === false || event.will_retry === false) return false;
|
|||
|
|
if (event.willRetry === true || event.will_retry === true) return true;
|
|||
|
|
const message = String(event.message || "").toLowerCase();
|
|||
|
|
if (!message) return false;
|
|||
|
|
return /\breconnecting\b/.test(message) || /\bretrying\b/.test(message);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Translate one Codex ThreadEvent into emitter calls.
|
|||
|
|
* `state` ({ reasoningOpen }) is threaded across events so reasoning summary
|
|||
|
|
* items render as a single collapsible thinking panel that closes when the first
|
|||
|
|
* non-reasoning content (assistant message / tool call) arrives.
|
|||
|
|
*/
|
|||
|
|
function translateCodexEvent(event, emitter, state) {
|
|||
|
|
if (!event || typeof event !== "object") return;
|
|||
|
|
const st = state || {};
|
|||
|
|
const closeReasoning = () => {
|
|||
|
|
if (st.reasoningOpen) { emitter.reasoningEnd(); st.reasoningOpen = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (event.type === "turn.failed") {
|
|||
|
|
closeReasoning();
|
|||
|
|
st.fatalError = true;
|
|||
|
|
emitter.emitError(event.error?.message || "Codex turn failed");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (event.type === "error") {
|
|||
|
|
const message = event.message || "Codex stream failed";
|
|||
|
|
if (isCodexRetryableStreamError(event)) {
|
|||
|
|
// Keep reasoning open — the turn is still in progress after Codex retries.
|
|||
|
|
const warningCount = (st.streamWarningCount = (st.streamWarningCount || 0) + 1);
|
|||
|
|
emitter.warning(`codex-stream-error:${warningCount}`, message);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
closeReasoning();
|
|||
|
|
st.fatalError = true;
|
|||
|
|
emitter.emitError(message);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (event.type === "turn.completed") {
|
|||
|
|
const usage = event.usage;
|
|||
|
|
const hasUsage = usage && [
|
|||
|
|
usage.input_tokens,
|
|||
|
|
usage.cached_input_tokens,
|
|||
|
|
usage.output_tokens,
|
|||
|
|
usage.reasoning_output_tokens,
|
|||
|
|
].some((value) => Number.isFinite(value));
|
|||
|
|
if (!hasUsage) return;
|
|||
|
|
const inputTokens = Number(usage.input_tokens) || 0;
|
|||
|
|
const outputTokens = Number(usage.output_tokens) || 0;
|
|||
|
|
emitter.usage({
|
|||
|
|
inputTokens,
|
|||
|
|
cachedInputTokens: Number(usage.cached_input_tokens) || 0,
|
|||
|
|
outputTokens,
|
|||
|
|
reasoningTokens: Number(usage.reasoning_output_tokens) || 0,
|
|||
|
|
totalTokens: inputTokens + outputTokens,
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!["item.started", "item.updated", "item.completed"].includes(event.type) || !event.item) return;
|
|||
|
|
|
|||
|
|
const item = event.item;
|
|||
|
|
|
|||
|
|
// Reasoning summary items feed the thinking panel. Codex may update the same
|
|||
|
|
// item with cumulative text before completion, so emit only the new suffix.
|
|||
|
|
if (item.type === "reasoning") {
|
|||
|
|
emitCodexReasoning(item, emitter, st);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
closeReasoning();
|
|||
|
|
|
|||
|
|
switch (item.type) {
|
|||
|
|
case "agent_message":
|
|||
|
|
if (event.type === "item.completed" && item.text) emitter.text(item.text);
|
|||
|
|
return;
|
|||
|
|
case "command_execution": {
|
|||
|
|
// Calibrated against @openai/codex-sdk CommandExecutionItem (command +
|
|||
|
|
// aggregated_output).
|
|||
|
|
emitCodexToolCallOnce(item, emitter, st, "shell", { command: item.command || "" });
|
|||
|
|
if (event.type === "item.completed" && item.aggregated_output) {
|
|||
|
|
emitCodexToolResultOnce(item, emitter, st, item.aggregated_output, "shell");
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
case "mcp_tool_call": {
|
|||
|
|
// Calibrated against @openai/codex-sdk McpToolCallItem (tool + arguments;
|
|||
|
|
// result.content is an MCP ContentBlock[], errors carry .message).
|
|||
|
|
const toolName = item.tool || "mcp_tool";
|
|||
|
|
emitCodexToolCallOnce(item, emitter, st, toolName, item.arguments || {});
|
|||
|
|
if (event.type === "item.completed") {
|
|||
|
|
emitCodexToolResultOnce(item, emitter, st, extractMcpResultText(item), toolName);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
case "file_change":
|
|||
|
|
if (event.type === "item.completed") {
|
|||
|
|
emitter.fileChange(
|
|||
|
|
item.id,
|
|||
|
|
Array.isArray(item.changes) ? item.changes : [],
|
|||
|
|
item.status === "failed" ? "failed" : "completed",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
case "web_search":
|
|||
|
|
emitter.webSearch(
|
|||
|
|
item.id,
|
|||
|
|
item.query || "",
|
|||
|
|
event.type === "item.completed" ? "completed" : "running",
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
case "todo_list":
|
|||
|
|
emitter.planUpdate(
|
|||
|
|
item.id,
|
|||
|
|
Array.isArray(item.items) ? item.items : [],
|
|||
|
|
event.type === "item.completed" ? "completed" : "running",
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
case "error":
|
|||
|
|
if (event.type === "item.completed") {
|
|||
|
|
emitter.warning(item.id, item.message || "Codex reported a recoverable error");
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
default:
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Run a Codex turn.
|
|||
|
|
* @param {object} args
|
|||
|
|
* @param {string} args.prompt
|
|||
|
|
* @param {Array<object>} [args.attachments]
|
|||
|
|
* @param {object} args.constructorOptions buildCodexConstructorOptions(...)
|
|||
|
|
* @param {object} args.threadOptions buildCodexThreadOptions(...) — model / sandboxMode / workingDirectory
|
|||
|
|
* @param {string} [args.resumeThreadId]
|
|||
|
|
* @param {object} args.emitter
|
|||
|
|
* @param {AbortSignal} [args.signal]
|
|||
|
|
* @param {Function} [args.CodexCtor] inject Codex class (for tests)
|
|||
|
|
*/
|
|||
|
|
async function runCodexTurn({
|
|||
|
|
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor,
|
|||
|
|
}) {
|
|||
|
|
const Codex = CodexCtor || (await import("@openai/codex-sdk")).Codex;
|
|||
|
|
const promptInput = buildCodexPromptInput(prompt, attachments);
|
|||
|
|
let threadId = null;
|
|||
|
|
try {
|
|||
|
|
const codex = new Codex(constructorOptions);
|
|||
|
|
// ThreadOptions (model + read-only sandbox + cwd) must be applied on resume too.
|
|||
|
|
const thread = resumeThreadId
|
|||
|
|
? codex.resumeThread(resumeThreadId, threadOptions)
|
|||
|
|
: codex.startThread(threadOptions);
|
|||
|
|
|
|||
|
|
const { events } = await thread.runStreamed(promptInput, signal ? { signal } : undefined);
|
|||
|
|
let hasContent = false;
|
|||
|
|
const state = { reasoningOpen: false };
|
|||
|
|
for await (const event of events) {
|
|||
|
|
// Capture + emit the resumable thread id as EARLY as possible — it exists
|
|||
|
|
// the moment `thread.started` arrives (the first event). Emitting it only at
|
|||
|
|
// the END of the turn (the old behavior) meant a mid-turn Stop never
|
|||
|
|
// persisted it, so the NEXT turn opened a fresh thread and the whole session
|
|||
|
|
// lost its memory. Verified: codex resume survives an aborted turn, so
|
|||
|
|
// preserving the id is enough to keep context across a Stop.
|
|||
|
|
if (!threadId) {
|
|||
|
|
const tid = thread.id || (event && event.type === "thread.started" ? event.thread_id : null);
|
|||
|
|
if (tid) { threadId = tid; emitter.sessionId(threadId); }
|
|||
|
|
}
|
|||
|
|
if (signal?.aborted) break;
|
|||
|
|
if (event?.type === "item.completed") hasContent = true;
|
|||
|
|
translateCodexEvent(event, emitter, state);
|
|||
|
|
if (state.fatalError) break;
|
|||
|
|
}
|
|||
|
|
if (state.reasoningOpen) emitter.reasoningEnd();
|
|||
|
|
if (!threadId) {
|
|||
|
|
threadId = thread.id || resumeThreadId || null;
|
|||
|
|
if (threadId) emitter.sessionId(threadId);
|
|||
|
|
}
|
|||
|
|
if (state.fatalError) {
|
|||
|
|
return { threadId };
|
|||
|
|
}
|
|||
|
|
if (!hasContent && !signal?.aborted) {
|
|||
|
|
emitter.emitError(
|
|||
|
|
"Codex returned an empty response. Reconnect Codex in Settings -> AI (codex login), " +
|
|||
|
|
"or configure a provider in ~/.codex/config.toml.",
|
|||
|
|
);
|
|||
|
|
return { threadId };
|
|||
|
|
}
|
|||
|
|
emitter.emitDone();
|
|||
|
|
return { threadId };
|
|||
|
|
} catch (error) {
|
|||
|
|
const code = error && error.code;
|
|||
|
|
const msg = String((error && error.message) || error || "");
|
|||
|
|
if (code === "ENOENT" || /ENOENT/i.test(msg)) {
|
|||
|
|
emitter.emitError(
|
|||
|
|
"Codex binary not found. Install with `npm i -g @openai/codex` (or `brew install --cask codex`).",
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
emitter.emitError(msg || "Codex turn failed");
|
|||
|
|
}
|
|||
|
|
return { threadId };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
buildCodexConstructorOptions,
|
|||
|
|
buildCodexThreadOptions,
|
|||
|
|
buildCodexPromptInput,
|
|||
|
|
parseCodexModelSelection,
|
|||
|
|
translateCodexEvent,
|
|||
|
|
runCodexTurn,
|
|||
|
|
toCodexMcpConfig,
|
|||
|
|
};
|