UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

1,158 lines (1,149 loc) 41.2 kB
'use strict'; var ink = require('ink'); var React = require('react'); var api = require('./types-XjAAlKci.cjs'); var index_js = require('@modelcontextprotocol/sdk/client/index.js'); var stdio_js = require('@modelcontextprotocol/sdk/client/stdio.js'); var z = require('zod'); var types_js = require('@modelcontextprotocol/sdk/types.js'); var child_process = require('child_process'); var setupOfflineReconnection = require('./setupOfflineReconnection-yi-1RV7-.cjs'); var node_crypto = require('node:crypto'); var persistence = require('./persistence-ByBDgr7f.cjs'); var index = require('./index-Chfz7o-q.cjs'); var os = require('node:os'); var node_path = require('node:path'); var fs = require('node:fs'); var constants = require('./constants-CeSb8ijt.cjs'); require('axios'); require('chalk'); require('fs'); require('node:events'); require('socket.io-client'); require('tweetnacl'); require('util'); require('fs/promises'); require('crypto'); require('path'); require('url'); require('os'); require('expo-server-sdk'); require('node:fs/promises'); require('node:child_process'); require('node:readline'); require('node:url'); require('node:util'); require('ps-list'); require('cross-spawn'); require('tmp'); require('qrcode-terminal'); require('open'); require('fastify'); require('fastify-type-provider-zod'); require('@modelcontextprotocol/sdk/server/mcp.js'); require('node:http'); require('@modelcontextprotocol/sdk/server/streamableHttp.js'); require('http'); const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1e3; function getCodexMcpCommand() { try { const version = child_process.execSync("codex --version", { encoding: "utf8" }).trim(); const match = version.match(/codex-cli\s+(\d+\.\d+\.\d+(?:-alpha\.\d+)?)/); if (!match) { api.logger.debug("[CodexMCP] Could not parse codex version:", version); return null; } const versionStr = match[1]; const [major, minor, patch] = versionStr.split(/[-.]/).map(Number); if (major > 0 || minor > 43) return "mcp-server"; if (minor === 43 && patch === 0) { if (versionStr.includes("-alpha.")) { const alphaNum = parseInt(versionStr.split("-alpha.")[1]); return alphaNum >= 5 ? "mcp-server" : "mcp"; } return "mcp-server"; } return "mcp"; } catch (error) { api.logger.debug("[CodexMCP] Codex CLI not found or not executable:", error); return null; } } class CodexMcpClient { client; transport = null; connected = false; sessionId = null; conversationId = null; handler = null; permissionHandler = null; constructor() { this.client = new index_js.Client( { name: "consortium-codex-client", version: "1.0.0" }, { capabilities: { elicitation: {} } } ); this.client.setNotificationHandler(z.z.object({ method: z.z.literal("codex/event"), params: z.z.object({ msg: z.z.any() }) }).passthrough(), (data) => { const msg = data.params.msg; this.updateIdentifiersFromEvent(msg); this.handler?.(msg); }); } setHandler(handler) { this.handler = handler; } /** * Set the permission handler for tool approval */ setPermissionHandler(handler) { this.permissionHandler = handler; } async connect() { if (this.connected) return; const mcpCommand = getCodexMcpCommand(); if (mcpCommand === null) { throw new Error( "Codex CLI not found or not executable.\n\nTo install codex:\n npm install -g @openai/codex\n\nAlternatively, use Claude:\n consortium claude" ); } api.logger.debug(`[CodexMCP] Connecting to Codex MCP server using command: codex ${mcpCommand}`); this.transport = new stdio_js.StdioClientTransport({ command: "codex", args: [mcpCommand], env: Object.keys(process.env).reduce((acc, key) => { const value = process.env[key]; if (typeof value === "string") acc[key] = value; return acc; }, {}) }); this.registerPermissionHandlers(); await this.client.connect(this.transport); this.connected = true; api.logger.debug("[CodexMCP] Connected to Codex"); } registerPermissionHandlers() { this.client.setRequestHandler( types_js.ElicitRequestSchema, async (request) => { console.log("[CodexMCP] Received elicitation request:", request.params); const params = request.params; const toolName = "CodexBash"; if (!this.permissionHandler) { api.logger.debug("[CodexMCP] No permission handler set, denying by default"); return { decision: "denied" }; } try { const result = await this.permissionHandler.handleToolCall( params.codex_call_id, toolName, { command: params.codex_command, cwd: params.codex_cwd } ); api.logger.debug("[CodexMCP] Permission result:", result); return { decision: result.decision }; } catch (error) { api.logger.debug("[CodexMCP] Error handling permission request:", error); return { decision: "denied", reason: error instanceof Error ? error.message : "Permission request failed" }; } } ); api.logger.debug("[CodexMCP] Permission handlers registered"); } async startSession(config, options) { if (!this.connected) await this.connect(); api.logger.debug("[CodexMCP] Starting Codex session:", config); const response = await this.client.callTool({ name: "codex", arguments: config }, void 0, { signal: options?.signal, timeout: DEFAULT_TIMEOUT // maxTotalTimeout: 10000000000 }); api.logger.debug("[CodexMCP] startSession response:", response); this.extractIdentifiers(response); return response; } async continueSession(prompt, options) { if (!this.connected) await this.connect(); if (!this.sessionId) { throw new Error("No active session. Call startSession first."); } if (!this.conversationId) { this.conversationId = this.sessionId; api.logger.debug("[CodexMCP] conversationId missing, defaulting to sessionId:", this.conversationId); } const args = { sessionId: this.sessionId, conversationId: this.conversationId, prompt }; api.logger.debug("[CodexMCP] Continuing Codex session:", args); const response = await this.client.callTool({ name: "codex-reply", arguments: args }, void 0, { signal: options?.signal, timeout: DEFAULT_TIMEOUT }); api.logger.debug("[CodexMCP] continueSession response:", response); this.extractIdentifiers(response); return response; } updateIdentifiersFromEvent(event) { if (!event || typeof event !== "object") { return; } const candidates = [event]; if (event.data && typeof event.data === "object") { candidates.push(event.data); } for (const candidate of candidates) { const sessionId = candidate.session_id ?? candidate.sessionId; if (sessionId) { this.sessionId = sessionId; api.logger.debug("[CodexMCP] Session ID extracted from event:", this.sessionId); } const conversationId = candidate.conversation_id ?? candidate.conversationId; if (conversationId) { this.conversationId = conversationId; api.logger.debug("[CodexMCP] Conversation ID extracted from event:", this.conversationId); } } } extractIdentifiers(response) { const meta = response?.meta || {}; if (meta.sessionId) { this.sessionId = meta.sessionId; api.logger.debug("[CodexMCP] Session ID extracted:", this.sessionId); } else if (response?.sessionId) { this.sessionId = response.sessionId; api.logger.debug("[CodexMCP] Session ID extracted:", this.sessionId); } if (meta.conversationId) { this.conversationId = meta.conversationId; api.logger.debug("[CodexMCP] Conversation ID extracted:", this.conversationId); } else if (response?.conversationId) { this.conversationId = response.conversationId; api.logger.debug("[CodexMCP] Conversation ID extracted:", this.conversationId); } const content = response?.content; if (Array.isArray(content)) { for (const item of content) { if (!this.sessionId && item?.sessionId) { this.sessionId = item.sessionId; api.logger.debug("[CodexMCP] Session ID extracted from content:", this.sessionId); } if (!this.conversationId && item && typeof item === "object" && "conversationId" in item && item.conversationId) { this.conversationId = item.conversationId; api.logger.debug("[CodexMCP] Conversation ID extracted from content:", this.conversationId); } } } } getSessionId() { return this.sessionId; } hasActiveSession() { return this.sessionId !== null; } clearSession() { const previousSessionId = this.sessionId; this.sessionId = null; this.conversationId = null; api.logger.debug("[CodexMCP] Session cleared, previous sessionId:", previousSessionId); } /** * Store the current session ID without clearing it, useful for abort handling */ storeSessionForResume() { api.logger.debug("[CodexMCP] Storing session for potential resume:", this.sessionId); return this.sessionId; } /** * Force close the Codex MCP transport and clear all session identifiers. * Use this for permanent shutdown (e.g. kill/exit). Prefer `disconnect()` for * transient connection resets where you may want to keep the session id. */ async forceCloseSession() { api.logger.debug("[CodexMCP] Force closing session"); try { await this.disconnect(); } finally { this.clearSession(); } api.logger.debug("[CodexMCP] Session force-closed"); } async disconnect() { if (!this.connected) return; const pid = this.transport?.pid ?? null; api.logger.debug(`[CodexMCP] Disconnecting; child pid=${pid ?? "none"}`); try { api.logger.debug("[CodexMCP] client.close begin"); await this.client.close(); api.logger.debug("[CodexMCP] client.close done"); } catch (e) { api.logger.debug("[CodexMCP] Error closing client, attempting transport close directly", e); try { api.logger.debug("[CodexMCP] transport.close begin"); await this.transport?.close?.(); api.logger.debug("[CodexMCP] transport.close done"); } catch { } } if (pid) { try { process.kill(pid, 0); api.logger.debug("[CodexMCP] Child still alive, sending SIGKILL"); try { process.kill(pid, "SIGKILL"); } catch { } } catch { } } this.transport = null; this.connected = false; api.logger.debug(`[CodexMCP] Disconnected; session ${this.sessionId ?? "none"} preserved`); } } class CodexPermissionHandler extends setupOfflineReconnection.BasePermissionHandler { constructor(session) { super(session); } getLogPrefix() { return "[Codex]"; } /** * Handle a tool permission request * @param toolCallId - The unique ID of the tool call * @param toolName - The name of the tool being called * @param input - The input parameters for the tool * @returns Promise resolving to permission result */ async handleToolCall(toolCallId, toolName, input) { return new Promise((resolve, reject) => { this.pendingRequests.set(toolCallId, { resolve, reject, toolName, input }); this.addPendingRequestToState(toolCallId, toolName, input); api.logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId})`); }); } } class ReasoningProcessor extends setupOfflineReconnection.BaseReasoningProcessor { getToolName() { return "CodexReasoning"; } getLogPrefix() { return "[ReasoningProcessor]"; } /** * Process a reasoning delta and accumulate content. */ processDelta(delta) { this.processInput(delta); } /** * Complete the reasoning section with final text. */ complete(fullText) { this.completeReasoning(fullText); } } class DiffProcessor { previousDiff = null; onMessage = null; constructor(onMessage) { this.onMessage = onMessage || null; } /** * Process a turn_diff message and check if the unified_diff has changed */ processDiff(unifiedDiff) { if (this.previousDiff !== unifiedDiff) { api.logger.debug("[DiffProcessor] Unified diff changed, sending CodexDiff tool call"); const callId = node_crypto.randomUUID(); const toolCall = { type: "tool-call", name: "CodexDiff", callId, input: { unified_diff: unifiedDiff }, id: node_crypto.randomUUID() }; this.onMessage?.(toolCall); const toolResult = { type: "tool-call-result", callId, output: { status: "completed" }, id: node_crypto.randomUUID() }; this.onMessage?.(toolResult); } this.previousDiff = unifiedDiff; api.logger.debug("[DiffProcessor] Updated stored diff"); } /** * Reset the processor state (called on task_complete or turn_aborted) */ reset() { api.logger.debug("[DiffProcessor] Resetting diff state"); this.previousDiff = null; } /** * Set the message callback for sending messages directly */ setMessageCallback(callback) { this.onMessage = callback; } /** * Get the current diff value */ getCurrentDiff() { return this.previousDiff; } } const CodexDisplay = ({ messageBuffer, logPath, onExit }) => { const [messages, setMessages] = React.useState([]); const [confirmationMode, setConfirmationMode] = React.useState(false); const [actionInProgress, setActionInProgress] = React.useState(false); const confirmationTimeoutRef = React.useRef(null); const { stdout } = ink.useStdout(); const terminalWidth = stdout.columns || 80; const terminalHeight = stdout.rows || 24; React.useEffect(() => { setMessages(messageBuffer.getMessages()); const unsubscribe = messageBuffer.onUpdate((newMessages) => { setMessages(newMessages); }); return () => { unsubscribe(); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); } }; }, [messageBuffer]); const resetConfirmation = React.useCallback(() => { setConfirmationMode(false); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); confirmationTimeoutRef.current = null; } }, []); const setConfirmationWithTimeout = React.useCallback(() => { setConfirmationMode(true); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); } confirmationTimeoutRef.current = setTimeout(() => { resetConfirmation(); }, 15e3); }, [resetConfirmation]); ink.useInput(React.useCallback(async (input, key) => { if (actionInProgress) return; if (key.ctrl && input === "c") { if (confirmationMode) { resetConfirmation(); setActionInProgress(true); await new Promise((resolve) => setTimeout(resolve, 100)); onExit?.(); } else { setConfirmationWithTimeout(); } return; } if (confirmationMode) { resetConfirmation(); } }, [confirmationMode, actionInProgress, onExit, setConfirmationWithTimeout, resetConfirmation])); const getMessageColor = (type) => { switch (type) { case "user": return "magenta"; case "assistant": return "cyan"; case "system": return "blue"; case "tool": return "yellow"; case "result": return "green"; case "status": return "gray"; default: return "white"; } }; const formatMessage = (msg) => { const lines = msg.content.split("\n"); const maxLineLength = terminalWidth - 10; return lines.map((line) => { if (line.length <= maxLineLength) return line; const chunks = []; for (let i = 0; i < line.length; i += maxLineLength) { chunks.push(line.slice(i, i + maxLineLength)); } return chunks.join("\n"); }).join("\n"); }; return /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", width: terminalWidth, height: terminalHeight }, /* @__PURE__ */ React.createElement( ink.Box, { flexDirection: "column", width: terminalWidth, height: terminalHeight - 4, borderStyle: "round", borderColor: "gray", paddingX: 1, overflow: "hidden" }, /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", bold: true }, "\u{1F916} Codex Agent Messages"), /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", dimColor: true }, "\u2500".repeat(Math.min(terminalWidth - 4, 60)))), /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", height: terminalHeight - 10, overflow: "hidden" }, messages.length === 0 ? /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", dimColor: true }, "Waiting for messages...") : ( // Show only the last messages that fit in the available space messages.slice(-Math.max(1, terminalHeight - 10)).map((msg) => /* @__PURE__ */ React.createElement(ink.Box, { key: msg.id, flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(ink.Text, { color: getMessageColor(msg.type), dimColor: true }, formatMessage(msg)))) )) ), /* @__PURE__ */ React.createElement( ink.Box, { width: terminalWidth, borderStyle: "round", borderColor: actionInProgress ? "gray" : confirmationMode ? "red" : "green", paddingX: 2, justifyContent: "center", alignItems: "center", flexDirection: "column" }, /* @__PURE__ */ React.createElement(ink.Box, { flexDirection: "column", alignItems: "center" }, actionInProgress ? /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", bold: true }, "Exiting agent...") : confirmationMode ? /* @__PURE__ */ React.createElement(ink.Text, { color: "red", bold: true }, "\u26A0\uFE0F Press Ctrl-C again to exit the agent") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(ink.Text, { color: "green", bold: true }, "\u{1F916} Codex Agent Running \u2022 Ctrl-C to exit")), process.env.DEBUG && logPath && /* @__PURE__ */ React.createElement(ink.Text, { color: "gray", dimColor: true }, "Debug logs: ", logPath)) )); }; function emitReadyIfIdle({ pending, queueSize, shouldExit, sendReady, notify }) { if (shouldExit) { return false; } if (pending) { return false; } if (queueSize() > 0) { return false; } sendReady(); notify?.(); return true; } async function runCodex(opts) { const sessionTag = node_crypto.randomUUID(); api.connectionState.setBackend("Codex"); const api$1 = await api.ApiClient.create(opts.credentials); api.logger.debug(`[codex] Starting with options: startedBy=${opts.startedBy || "terminal"}`); const settings = await persistence.readSettings(); let machineId = settings?.machineId; if (!machineId) { console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/ConsortiumAI/consortium-cli/issues`); process.exit(1); } api.logger.debug(`Using machineId: ${machineId}`); await api$1.getOrCreateMachine({ machineId, metadata: index.initialMachineMetadata }); const { state, metadata } = setupOfflineReconnection.createSessionMetadata({ flavor: "codex", machineId, startedBy: opts.startedBy }); const response = await api$1.getOrCreateSession({ tag: sessionTag, metadata, state }); let session; let permissionHandler; const { session: initialSession, reconnectionHandle } = setupOfflineReconnection.setupOfflineReconnection({ api: api$1, sessionTag, metadata, state, response, onSessionSwap: (newSession) => { session = newSession; if (permissionHandler) { permissionHandler.updateSession(newSession); } } }); session = initialSession; if (response) { try { api.logger.debug(`[START] Reporting session ${response.id} to daemon`); const result = await index.notifyDaemonSessionStarted(response.id, metadata); if (result.error) { api.logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); } else { api.logger.debug(`[START] Reported session ${response.id} to daemon`); } } catch (error) { api.logger.debug("[START] Failed to report to daemon (may not be running):", error); } } const messageQueue = new index.MessageQueue2((mode) => index.hashObject({ permissionMode: mode.permissionMode, model: mode.model })); let currentPermissionMode = void 0; let currentModel = void 0; session.onUserMessage((message) => { let messagePermissionMode = currentPermissionMode; if (message.meta?.permissionMode) { messagePermissionMode = message.meta.permissionMode; currentPermissionMode = messagePermissionMode; api.logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); } else { api.logger.debug(`[Codex] User message received with no permission mode override, using current: ${currentPermissionMode ?? "default (effective)"}`); } let messageModel = currentModel; if (message.meta?.hasOwnProperty("model")) { messageModel = message.meta.model || void 0; currentModel = messageModel; api.logger.debug(`[Codex] Model updated from user message: ${messageModel || "reset to default"}`); } else { api.logger.debug(`[Codex] User message received with no model override, using current: ${currentModel || "default"}`); } const enhancedMode = { permissionMode: messagePermissionMode || "default", model: messageModel }; messageQueue.push(message.content.text, enhancedMode); }); let thinking = false; session.keepAlive(thinking, "remote"); const keepAliveInterval = setInterval(() => { session.keepAlive(thinking, "remote"); }, 2e3); const sendReady = () => { session.sendSessionEvent({ type: "ready" }); try { api$1.push().sendToAllDevices( "It's ready!", "Codex is waiting for your command", { sessionId: session.sessionId } ); } catch (pushError) { api.logger.debug("[Codex] Failed to send ready push", pushError); } }; function logActiveHandles(tag) { if (!process.env.DEBUG) return; const anyProc = process; const handles = typeof anyProc._getActiveHandles === "function" ? anyProc._getActiveHandles() : []; const requests = typeof anyProc._getActiveRequests === "function" ? anyProc._getActiveRequests() : []; api.logger.debug(`[codex][handles] ${tag}: handles=${handles.length} requests=${requests.length}`); try { const kinds = handles.map((h) => h && h.constructor ? h.constructor.name : typeof h); api.logger.debug(`[codex][handles] kinds=${JSON.stringify(kinds)}`); } catch { } } let abortController = new AbortController(); let shouldExit = false; let storedSessionIdForResume = null; async function handleAbort() { api.logger.debug("[Codex] Abort requested - stopping current task"); try { if (client.hasActiveSession()) { storedSessionIdForResume = client.storeSessionForResume(); api.logger.debug("[Codex] Stored session for resume:", storedSessionIdForResume); } abortController.abort(); reasoningProcessor.abort(); api.logger.debug("[Codex] Abort completed - session remains active"); } catch (error) { api.logger.debug("[Codex] Error during abort:", error); } finally { abortController = new AbortController(); } } const handleKillSession = async () => { api.logger.debug("[Codex] Kill session requested - terminating process"); await handleAbort(); api.logger.debug("[Codex] Abort completed, proceeding with termination"); try { if (session) { session.updateMetadata((currentMetadata) => ({ ...currentMetadata, lifecycleState: "archived", lifecycleStateSince: Date.now(), archivedBy: "cli", archiveReason: "User terminated" })); session.sendSessionDeath(); await session.flush(); await session.close(); } try { await client.forceCloseSession(); } catch (e) { api.logger.debug("[Codex] Error while force closing Codex session during termination", e); } index.stopCaffeinate(); consortiumServer.stop(); api.logger.debug("[Codex] Session termination complete, exiting"); process.exit(0); } catch (error) { api.logger.debug("[Codex] Error during session termination:", error); process.exit(1); } }; session.rpcHandlerManager.registerHandler("abort", handleAbort); index.registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); const messageBuffer = new index.MessageBuffer(); const hasTTY = process.stdout.isTTY && process.stdin.isTTY; let inkInstance = null; if (hasTTY) { console.clear(); inkInstance = ink.render(React.createElement(CodexDisplay, { messageBuffer, logPath: process.env.DEBUG ? api.logger.getLogPath() : void 0, onExit: async () => { api.logger.debug("[codex]: Exiting agent via Ctrl-C"); shouldExit = true; await handleAbort(); } }), { exitOnCtrlC: false, patchConsole: false }); } if (hasTTY) { process.stdin.resume(); if (process.stdin.isTTY) { process.stdin.setRawMode(true); } process.stdin.setEncoding("utf8"); } const client = new CodexMcpClient(); function findCodexResumeFile(sessionId) { if (!sessionId) return null; try { let collectFilesRecursive2 = function(dir, acc = []) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; } for (const entry of entries) { const full = node_path.join(dir, entry.name); if (entry.isDirectory()) { collectFilesRecursive2(full, acc); } else if (entry.isFile()) { acc.push(full); } } return acc; }; var collectFilesRecursive = collectFilesRecursive2; const codexHomeDir = process.env.CODEX_HOME || node_path.join(os.homedir(), ".codex"); const rootDir = node_path.join(codexHomeDir, "sessions"); const candidates = collectFilesRecursive2(rootDir).filter((full) => full.endsWith(`-${sessionId}.jsonl`)).filter((full) => { try { return fs.statSync(full).isFile(); } catch { return false; } }).sort((a, b) => { const sa = fs.statSync(a).mtimeMs; const sb = fs.statSync(b).mtimeMs; return sb - sa; }); return candidates[0] || null; } catch { return null; } } permissionHandler = new CodexPermissionHandler(session); const reasoningProcessor = new ReasoningProcessor((message) => { session.sendCodexMessage(message); }); const diffProcessor = new DiffProcessor((message) => { session.sendCodexMessage(message); }); client.setPermissionHandler(permissionHandler); client.setHandler((msg) => { api.logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`); if (msg.type === "agent_message") { messageBuffer.addMessage(msg.message, "assistant"); } else if (msg.type === "agent_reasoning_delta") ; else if (msg.type === "agent_reasoning") { messageBuffer.addMessage(`[Thinking] ${msg.text.substring(0, 100)}...`, "system"); } else if (msg.type === "exec_command_begin") { messageBuffer.addMessage(`Executing: ${msg.command}`, "tool"); } else if (msg.type === "exec_command_end") { const output = msg.output || msg.error || "Command completed"; const truncatedOutput = output.substring(0, 200); messageBuffer.addMessage( `Result: ${truncatedOutput}${output.length > 200 ? "..." : ""}`, "result" ); } else if (msg.type === "task_started") { messageBuffer.addMessage("Starting task...", "status"); } else if (msg.type === "task_complete") { messageBuffer.addMessage("Task completed", "status"); sendReady(); } else if (msg.type === "turn_aborted") { messageBuffer.addMessage("Turn aborted", "status"); sendReady(); } if (msg.type === "task_started") { if (!thinking) { api.logger.debug("thinking started"); thinking = true; session.keepAlive(thinking, "remote"); } } if (msg.type === "task_complete" || msg.type === "turn_aborted") { if (thinking) { api.logger.debug("thinking completed"); thinking = false; session.keepAlive(thinking, "remote"); } diffProcessor.reset(); } if (msg.type === "agent_reasoning_section_break") { reasoningProcessor.handleSectionBreak(); } if (msg.type === "agent_reasoning_delta") { reasoningProcessor.processDelta(msg.delta); } if (msg.type === "agent_reasoning") { reasoningProcessor.complete(msg.text); } if (msg.type === "agent_message") { session.sendCodexMessage({ type: "message", message: msg.message, id: node_crypto.randomUUID() }); } if (msg.type === "exec_command_begin" || msg.type === "exec_approval_request") { let { call_id, type, ...inputs } = msg; session.sendCodexMessage({ type: "tool-call", name: "CodexBash", callId: call_id, input: inputs, id: node_crypto.randomUUID() }); } if (msg.type === "exec_command_end") { let { call_id, type, ...output } = msg; session.sendCodexMessage({ type: "tool-call-result", callId: call_id, output, id: node_crypto.randomUUID() }); } if (msg.type === "token_count") { session.sendCodexMessage({ ...msg, id: node_crypto.randomUUID() }); } if (msg.type === "patch_apply_begin") { let { call_id, auto_approved, changes } = msg; const changeCount = Object.keys(changes).length; const filesMsg = changeCount === 1 ? "1 file" : `${changeCount} files`; messageBuffer.addMessage(`Modifying ${filesMsg}...`, "tool"); session.sendCodexMessage({ type: "tool-call", name: "CodexPatch", callId: call_id, input: { auto_approved, changes }, id: node_crypto.randomUUID() }); } if (msg.type === "patch_apply_end") { let { call_id, stdout, stderr, success } = msg; if (success) { const message = stdout || "Files modified successfully"; messageBuffer.addMessage(message.substring(0, 200), "result"); } else { const errorMsg = stderr || "Failed to modify files"; messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, "result"); } session.sendCodexMessage({ type: "tool-call-result", callId: call_id, output: { stdout, stderr, success }, id: node_crypto.randomUUID() }); } if (msg.type === "turn_diff") { if (msg.unified_diff) { diffProcessor.processDiff(msg.unified_diff); } } }); const consortiumServer = await index.startConsortiumServer(session); const bridgeCommand = node_path.join(api.projectPath(), "bin", "consortium-mcp.mjs"); const mcpServers = { consortium: { command: bridgeCommand, args: ["--url", consortiumServer.url] } }; let first = true; try { api.logger.debug("[codex]: client.connect begin"); await client.connect(); api.logger.debug("[codex]: client.connect done"); let wasCreated = false; let currentModeHash = null; let pending = null; let nextExperimentalResume = null; while (!shouldExit) { logActiveHandles("loop-top"); let message = pending; pending = null; if (!message) { const waitSignal = abortController.signal; const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); if (!batch) { if (waitSignal.aborted && !shouldExit) { api.logger.debug("[codex]: Wait aborted while idle; ignoring and continuing"); continue; } api.logger.debug(`[codex]: batch=${!!batch}, shouldExit=${shouldExit}`); break; } message = batch; } if (!message) { break; } if (wasCreated && currentModeHash && message.hash !== currentModeHash) { api.logger.debug("[Codex] Mode changed \u2013 restarting Codex session"); messageBuffer.addMessage("\u2550".repeat(40), "status"); messageBuffer.addMessage("Starting new Codex session (mode changed)...", "status"); try { const prevSessionId = client.getSessionId(); nextExperimentalResume = findCodexResumeFile(prevSessionId); if (nextExperimentalResume) { api.logger.debug(`[Codex] Found resume file for session ${prevSessionId}: ${nextExperimentalResume}`); messageBuffer.addMessage("Resuming previous context\u2026", "status"); } else { api.logger.debug("[Codex] No resume file found for previous session"); } } catch (e) { api.logger.debug("[Codex] Error while searching resume file", e); } client.clearSession(); wasCreated = false; currentModeHash = null; pending = message; permissionHandler.reset(); reasoningProcessor.abort(); diffProcessor.reset(); thinking = false; session.keepAlive(thinking, "remote"); continue; } messageBuffer.addMessage(message.message, "user"); currentModeHash = message.hash; try { const approvalPolicy = (() => { switch (message.mode.permissionMode) { // Codex native modes case "default": return "untrusted"; // Ask for non-trusted commands case "read-only": return "never"; // Never ask, read-only enforced by sandbox case "safe-yolo": return "on-failure"; // Auto-run, ask only on failure case "yolo": return "on-failure"; // Auto-run, ask only on failure // Defensive fallback for Claude-specific modes (backward compatibility) case "bypassPermissions": return "on-failure"; // Full access: map to yolo behavior case "acceptEdits": return "on-request"; // Let model decide (closest to auto-approve edits) case "plan": return "untrusted"; // Conservative: ask for non-trusted default: return "untrusted"; } })(); const sandbox = (() => { switch (message.mode.permissionMode) { // Codex native modes case "default": return "workspace-write"; // Can write in workspace case "read-only": return "read-only"; // Read-only filesystem case "safe-yolo": return "workspace-write"; // Can write in workspace case "yolo": return "danger-full-access"; // Full system access // Defensive fallback for Claude-specific modes case "bypassPermissions": return "danger-full-access"; // Full access: map to yolo case "acceptEdits": return "workspace-write"; // Can edit files in workspace case "plan": return "workspace-write"; // Can write for planning default: return "workspace-write"; } })(); if (!wasCreated) { const startConfig = { prompt: first ? message.message + "\n\n" + constants.CHANGE_TITLE_INSTRUCTION : message.message, sandbox, "approval-policy": approvalPolicy, config: { mcp_servers: mcpServers } }; if (message.mode.model) { startConfig.model = message.mode.model; } let resumeFile = null; if (nextExperimentalResume) { resumeFile = nextExperimentalResume; nextExperimentalResume = null; api.logger.debug("[Codex] Using resume file from mode change:", resumeFile); } else if (storedSessionIdForResume) { const abortResumeFile = findCodexResumeFile(storedSessionIdForResume); if (abortResumeFile) { resumeFile = abortResumeFile; api.logger.debug("[Codex] Using resume file from aborted session:", resumeFile); messageBuffer.addMessage("Resuming from aborted session...", "status"); } storedSessionIdForResume = null; } if (resumeFile) { startConfig.config.experimental_resume = resumeFile; } await client.startSession( startConfig, { signal: abortController.signal } ); wasCreated = true; first = false; } else { const response2 = await client.continueSession( message.message, { signal: abortController.signal } ); api.logger.debug("[Codex] continueSession response:", response2); } } catch (error) { api.logger.warn("Error in codex session:", error); const isAbortError = error instanceof Error && error.name === "AbortError"; if (isAbortError) { messageBuffer.addMessage("Aborted by user", "status"); session.sendSessionEvent({ type: "message", message: "Aborted by user" }); } else { messageBuffer.addMessage("Process exited unexpectedly", "status"); session.sendSessionEvent({ type: "message", message: "Process exited unexpectedly" }); if (client.hasActiveSession()) { storedSessionIdForResume = client.storeSessionForResume(); api.logger.debug("[Codex] Stored session after unexpected error:", storedSessionIdForResume); } } } finally { permissionHandler.reset(); reasoningProcessor.abort(); diffProcessor.reset(); thinking = false; session.keepAlive(thinking, "remote"); emitReadyIfIdle({ pending, queueSize: () => messageQueue.size(), shouldExit, sendReady }); logActiveHandles("after-turn"); } } } finally { api.logger.debug("[codex]: Final cleanup start"); logActiveHandles("cleanup-start"); if (reconnectionHandle) { api.logger.debug("[codex]: Cancelling offline reconnection"); reconnectionHandle.cancel(); } try { api.logger.debug("[codex]: sendSessionDeath"); session.sendSessionDeath(); api.logger.debug("[codex]: flush begin"); await session.flush(); api.logger.debug("[codex]: flush done"); api.logger.debug("[codex]: session.close begin"); await session.close(); api.logger.debug("[codex]: session.close done"); } catch (e) { api.logger.debug("[codex]: Error while closing session", e); } api.logger.debug("[codex]: client.forceCloseSession begin"); await client.forceCloseSession(); api.logger.debug("[codex]: client.forceCloseSession done"); api.logger.debug("[codex]: consortiumServer.stop"); consortiumServer.stop(); if (process.stdin.isTTY) { api.logger.debug("[codex]: setRawMode(false)"); try { process.stdin.setRawMode(false); } catch { } } if (hasTTY) { api.logger.debug("[codex]: stdin.pause()"); try { process.stdin.pause(); } catch { } } api.logger.debug("[codex]: clearInterval(keepAlive)"); clearInterval(keepAliveInterval); if (inkInstance) { api.logger.debug("[codex]: inkInstance.unmount()"); inkInstance.unmount(); } messageBuffer.clear(); logActiveHandles("cleanup-end"); api.logger.debug("[codex]: Final cleanup completed"); } } exports.emitReadyIfIdle = emitReadyIfIdle; exports.runCodex = runCodex;