feat: new codex remote mode

This commit is contained in:
weishu
2026-01-26 22:22:32 +08:00
parent 6acde5a9b9
commit 3a1379dc01
8 changed files with 1644 additions and 356 deletions
+377 -356
View File
@@ -1,10 +1,8 @@
import React from 'react';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
import fs from 'node:fs';
import { join } from 'node:path';
import { CodexMcpClient } from './codexMcpClient';
import { CodexAppServerClient } from './codexAppServerClient';
import { CodexPermissionHandler } from './utils/permissionHandler';
import { ReasoningProcessor } from './utils/reasoningProcessor';
import { DiffProcessor } from './utils/diffProcessor';
@@ -17,7 +15,9 @@ import type { CodexSession } from './session';
import type { EnhancedMode } from './loop';
import { hasCodexCliOverrides } from './utils/codexCliOverrides';
import { buildCodexStartConfig } from './utils/codexStartConfig';
import { convertCodexEvent } from './utils/codexEventConverter';
import { AppServerEventConverter } from './utils/appServerEventConverter';
import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter';
import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig';
import {
RemoteLauncherBase,
type RemoteLauncherDisplayContext,
@@ -26,20 +26,30 @@ import {
type HappyServer = Awaited<ReturnType<typeof buildHapiMcpBridge>>['server'];
function shouldUseAppServer(): boolean {
const useMcpServer = process.env.CODEX_USE_MCP_SERVER === '1';
return !useMcpServer;
}
class CodexRemoteLauncher extends RemoteLauncherBase {
private readonly session: CodexSession;
private readonly client: CodexMcpClient;
private readonly useAppServer: boolean;
private readonly mcpClient: CodexMcpClient | null;
private readonly appServerClient: CodexAppServerClient | null;
private permissionHandler: CodexPermissionHandler | null = null;
private reasoningProcessor: ReasoningProcessor | null = null;
private diffProcessor: DiffProcessor | null = null;
private happyServer: HappyServer | null = null;
private abortController: AbortController = new AbortController();
private storedSessionIdForResume: string | null = null;
private currentThreadId: string | null = null;
private currentTurnId: string | null = null;
constructor(session: CodexSession) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
this.client = new CodexMcpClient();
this.useAppServer = shouldUseAppServer();
this.mcpClient = this.useAppServer ? null : new CodexMcpClient();
this.appServerClient = this.useAppServer ? new CodexAppServerClient() : null;
}
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
@@ -49,9 +59,19 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
private async handleAbort(): Promise<void> {
logger.debug('[Codex] Abort requested - stopping current task');
try {
if (this.client.hasActiveSession()) {
this.storedSessionIdForResume = this.client.storeSessionForResume();
logger.debug('[Codex] Stored session for resume:', this.storedSessionIdForResume);
if (this.useAppServer && this.appServerClient) {
if (this.currentThreadId && this.currentTurnId) {
try {
await this.appServerClient.interruptTurn({
threadId: this.currentThreadId,
turnId: this.currentTurnId
});
} catch (error) {
logger.debug('[Codex] Error interrupting app-server turn:', error);
}
}
this.currentTurnId = null;
}
this.abortController.abort();
@@ -107,52 +127,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
protected async runMainLoop(): Promise<void> {
const session = this.session;
const messageBuffer = this.messageBuffer;
const client = this.client;
function findCodexResumeFile(sessionId: string | null): string | null {
if (!sessionId) return null;
try {
const codexHomeDir = process.env.CODEX_HOME || join(os.homedir(), '.codex');
const rootDir = join(codexHomeDir, 'sessions');
function collectFilesRecursive(dir: string, acc: string[] = []): string[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return acc;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
collectFilesRecursive(full, acc);
} else if (entry.isFile()) {
acc.push(full);
}
}
return acc;
}
const candidates = collectFilesRecursive(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;
}
}
const RESUME_CONTEXT_MAX_ITEMS = 40;
const RESUME_CONTEXT_MAX_CHARS = 16000;
const RESUME_CONTEXT_TOOL_MAX_CHARS = 2000;
const RESUME_CONTEXT_REASONING_MAX_CHARS = 2000;
const useAppServer = this.useAppServer;
const mcpClient = this.mcpClient;
const appServerClient = this.appServerClient;
const appServerEventConverter = useAppServer ? new AppServerEventConverter() : null;
const normalizeCommand = (value: unknown): string | undefined => {
if (typeof value === 'string') {
@@ -166,150 +144,27 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
return undefined;
};
const normalizeCwd = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const permissionDetails = new Map<string, { command?: string; cwd?: string }>();
const recordPermissionDetails = (id: string, command?: string, cwd?: string) => {
const existing = permissionDetails.get(id) ?? {};
const next = {
command: command ?? existing.command,
cwd: cwd ?? existing.cwd
};
permissionDetails.set(id, next);
return next;
};
const getPermissionDetails = (id: string) => permissionDetails.get(id) ?? {};
function readResumeFileContent(resumeFile: string): { content: string; truncated: boolean } | null {
try {
const stat = fs.statSync(resumeFile);
if (!stat.isFile()) {
return null;
}
return { content: fs.readFileSync(resumeFile, 'utf8'), truncated: false };
} catch (error) {
logger.debug('[Codex] Failed to read resume file:', error);
const asRecord = (value: unknown): Record<string, unknown> | null => {
if (!value || typeof value !== 'object') {
return null;
}
}
return value as Record<string, unknown>;
};
function safeStringify(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string') {
return value;
}
const asString = (value: unknown): string | null => {
return typeof value === 'string' && value.length > 0 ? value : null;
};
const formatOutputPreview = (value: unknown): string => {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (value === null || value === undefined) return '';
try {
return JSON.stringify(value);
} catch {
return null;
return String(value);
}
}
function formatResumeValue(value: unknown, maxChars: number, singleLine = false): string | null {
const raw = safeStringify(value);
if (!raw) {
return null;
}
const normalized = singleLine ? raw.replace(/\s+/g, ' ').trim() : raw;
if (!normalized) {
return null;
}
if (normalized.length <= maxChars) {
return normalized;
}
return `${normalized.slice(0, maxChars)}...`;
}
function buildResumeInstructionsFromFile(resumeFile: string): string | undefined {
const result = readResumeFileContent(resumeFile);
if (!result) {
return undefined;
}
const items: { role: 'user' | 'assistant' | 'tool'; text: string }[] = [];
let truncated = result.truncated;
const lines = result.content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed);
const converted = convertCodexEvent(parsed);
if (converted?.userMessage) {
items.push({ role: 'user', text: converted.userMessage });
}
if (converted?.message?.type === 'message') {
items.push({ role: 'assistant', text: converted.message.message });
}
if (converted?.message?.type === 'reasoning') {
const reasoning = formatResumeValue(converted.message.message, RESUME_CONTEXT_REASONING_MAX_CHARS);
if (reasoning) {
items.push({ role: 'assistant', text: `Reasoning: ${reasoning}` });
}
}
if (converted?.message?.type === 'tool-call') {
const input = formatResumeValue(converted.message.input, RESUME_CONTEXT_TOOL_MAX_CHARS, true);
const text = input
? `Call ${converted.message.name} ${input}`
: `Call ${converted.message.name}`;
items.push({ role: 'tool', text });
}
if (converted?.message?.type === 'tool-call-result') {
const output = formatResumeValue(converted.message.output, RESUME_CONTEXT_TOOL_MAX_CHARS, true);
if (output) {
items.push({ role: 'tool', text: `Result ${output}` });
}
}
} catch {
continue;
}
}
if (items.length === 0) {
return undefined;
}
if (items.length > RESUME_CONTEXT_MAX_ITEMS) {
items.splice(0, items.length - RESUME_CONTEXT_MAX_ITEMS);
truncated = true;
}
const rendered = items.map((item) => {
if (item.role === 'user') {
return `User: ${item.text}`;
}
if (item.role === 'tool') {
return `Tool: ${item.text}`;
}
return `Assistant: ${item.text}`;
});
let totalChars = rendered.reduce((sum, line) => sum + line.length + 1, 0);
while (rendered.length > 1 && totalChars > RESUME_CONTEXT_MAX_CHARS) {
const removed = rendered.shift();
totalChars -= (removed?.length ?? 0) + 1;
truncated = true;
}
if (rendered.length === 0) {
return undefined;
}
const header = truncated
? 'Continue from the prior session context below (transcript truncated):'
: 'Continue from the prior session context below:';
return `${header}\n${rendered.join('\n')}`;
}
};
const permissionHandler = new CodexPermissionHandler(session.client, {
onRequest: ({ id, toolName, input }) => {
@@ -360,140 +215,234 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
this.reasoningProcessor = reasoningProcessor;
this.diffProcessor = diffProcessor;
client.setPermissionHandler(permissionHandler);
client.setHandler((msg) => {
logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`);
const handleCodexEvent = (msg: Record<string, unknown>) => {
const msgType = asString(msg.type);
if (!msgType) return;
const msgType = typeof msg?.type === 'string' ? msg.type : null;
if (msgType === 'event_msg' || msgType === 'response_item' || msgType === 'session_meta') {
const payloadType = typeof msg?.payload?.type === 'string' ? msg.payload.type : null;
logger.debug(`[Codex] MCP wrapper event type: ${msgType}${payloadType ? ` (payload=${payloadType})` : ''}`);
if (msgType === 'thread_started') {
const threadId = asString(msg.thread_id ?? msg.threadId);
if (threadId) {
this.currentThreadId = threadId;
session.onSessionFound(threadId);
}
return;
}
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);
if (msgType === 'task_started') {
const turnId = asString(msg.turn_id ?? msg.turnId);
if (turnId) {
this.currentTurnId = turnId;
}
}
if (msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') {
this.currentTurnId = null;
}
if (!useAppServer) {
logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`);
if (msgType === 'event_msg' || msgType === 'response_item' || msgType === 'session_meta') {
const payload = asRecord(msg.payload);
const payloadType = asString(payload?.type);
logger.debug(`[Codex] MCP wrapper event type: ${msgType}${payloadType ? ` (payload=${payloadType})` : ''}`);
}
}
if (msgType === 'agent_message') {
const message = asString(msg.message);
if (message) {
messageBuffer.addMessage(message, 'assistant');
}
} else if (msgType === 'agent_reasoning') {
const text = asString(msg.text);
if (text) {
messageBuffer.addMessage(`[Thinking] ${text.substring(0, 100)}...`, 'system');
}
} else if (msgType === 'exec_command_begin') {
const command = normalizeCommand(msg.command) ?? 'command';
messageBuffer.addMessage(`Executing: ${command}`, 'tool');
} else if (msgType === 'exec_command_end') {
const output = msg.output ?? msg.error ?? 'Command completed';
const outputText = formatOutputPreview(output);
const truncatedOutput = outputText.substring(0, 200);
messageBuffer.addMessage(
`Result: ${truncatedOutput}${output.length > 200 ? '...' : ''}`,
`Result: ${truncatedOutput}${outputText.length > 200 ? '...' : ''}`,
'result'
);
} else if (msg.type === 'task_started') {
} else if (msgType === 'task_started') {
messageBuffer.addMessage('Starting task...', 'status');
} else if (msg.type === 'task_complete') {
} else if (msgType === 'task_complete') {
messageBuffer.addMessage('Task completed', 'status');
sendReady();
} else if (msg.type === 'turn_aborted') {
} else if (msgType === 'turn_aborted') {
messageBuffer.addMessage('Turn aborted', 'status');
sendReady();
} else if (msgType === 'task_failed') {
const error = asString(msg.error);
messageBuffer.addMessage(error ? `Task failed: ${error}` : 'Task failed', 'status');
sendReady();
}
if (msg.type === 'task_started') {
if (msgType === 'task_started') {
if (useAppServer) {
turnInFlight = true;
}
if (!session.thinking) {
logger.debug('thinking started');
session.onThinkingChange(true);
}
}
if (msg.type === 'task_complete' || msg.type === 'turn_aborted') {
if (msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') {
if (useAppServer) {
turnInFlight = false;
}
if (session.thinking) {
logger.debug('thinking completed');
session.onThinkingChange(false);
}
diffProcessor.reset();
appServerEventConverter?.reset();
}
if (msg.type === 'agent_reasoning_section_break') {
if (msgType === 'agent_reasoning_section_break') {
reasoningProcessor.handleSectionBreak();
}
if (msg.type === 'agent_reasoning_delta') {
reasoningProcessor.processDelta(msg.delta);
if (msgType === 'agent_reasoning_delta') {
const delta = asString(msg.delta);
if (delta) {
reasoningProcessor.processDelta(delta);
}
}
if (msg.type === 'agent_reasoning') {
reasoningProcessor.complete(msg.text);
if (msgType === 'agent_reasoning') {
const text = asString(msg.text);
if (text) {
reasoningProcessor.complete(text);
}
}
if (msg.type === 'agent_message') {
session.sendCodexMessage({
type: 'message',
message: msg.message,
id: randomUUID()
});
if (msgType === 'agent_message') {
const message = asString(msg.message);
if (message) {
session.sendCodexMessage({
type: 'message',
message,
id: randomUUID()
});
}
}
if (msg.type === 'exec_command_begin' || msg.type === 'exec_approval_request') {
const { call_id, type, ...inputs } = msg;
session.sendCodexMessage({
type: 'tool-call',
name: 'CodexBash',
callId: call_id,
input: inputs,
id: randomUUID()
});
if (msgType === 'exec_command_begin' || msgType === 'exec_approval_request') {
const callId = asString(msg.call_id ?? msg.callId);
if (callId) {
const inputs: Record<string, unknown> = { ...msg };
delete inputs.type;
delete inputs.call_id;
delete inputs.callId;
session.sendCodexMessage({
type: 'tool-call',
name: 'CodexBash',
callId: callId,
input: inputs,
id: randomUUID()
});
}
}
if (msg.type === 'exec_command_end') {
const { call_id, type, ...output } = msg;
session.sendCodexMessage({
type: 'tool-call-result',
callId: call_id,
output,
id: randomUUID()
});
if (msgType === 'exec_command_end') {
const callId = asString(msg.call_id ?? msg.callId);
if (callId) {
const output: Record<string, unknown> = { ...msg };
delete output.type;
delete output.call_id;
delete output.callId;
session.sendCodexMessage({
type: 'tool-call-result',
callId: callId,
output,
id: randomUUID()
});
}
}
if (msg.type === 'token_count') {
if (msgType === 'token_count') {
session.sendCodexMessage({
...msg,
id: randomUUID()
});
}
if (msg.type === 'patch_apply_begin') {
const { call_id, auto_approved, changes } = msg;
if (msgType === 'patch_apply_begin') {
const callId = asString(msg.call_id ?? msg.callId);
if (callId) {
const changes = asRecord(msg.changes) ?? {};
const changeCount = Object.keys(changes).length;
const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`;
messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool');
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: randomUUID()
});
}
if (msg.type === 'patch_apply_end') {
const { 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: randomUUID()
});
}
if (msg.type === 'turn_diff') {
if (msg.unified_diff) {
diffProcessor.processDiff(msg.unified_diff);
session.sendCodexMessage({
type: 'tool-call',
name: 'CodexPatch',
callId: callId,
input: {
auto_approved: msg.auto_approved ?? msg.autoApproved,
changes
},
id: randomUUID()
});
}
}
});
if (msgType === 'patch_apply_end') {
const callId = asString(msg.call_id ?? msg.callId);
if (callId) {
const stdout = asString(msg.stdout);
const stderr = asString(msg.stderr);
const success = Boolean(msg.success);
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: callId,
output: {
stdout,
stderr,
success
},
id: randomUUID()
});
}
}
if (msgType === 'turn_diff') {
const diff = asString(msg.unified_diff);
if (diff) {
diffProcessor.processDiff(diff);
}
}
};
if (useAppServer && appServerClient && appServerEventConverter) {
registerAppServerPermissionHandlers({
client: appServerClient,
permissionHandler
});
appServerClient.setNotificationHandler((method, params) => {
const events = appServerEventConverter.handleNotification(method, params);
for (const event of events) {
const eventRecord = asRecord(event) ?? { type: undefined };
handleCodexEvent(eventRecord);
}
});
} else if (mcpClient) {
mcpClient.setPermissionHandler(permissionHandler);
mcpClient.setHandler((msg) => {
const eventRecord = asRecord(msg) ?? { type: undefined };
handleCodexEvent(eventRecord);
});
}
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
this.happyServer = happyServer;
@@ -520,19 +469,30 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
};
const syncSessionId = () => {
const clientSessionId = client.getSessionId();
if (!mcpClient) return;
const clientSessionId = mcpClient.getSessionId();
if (clientSessionId && clientSessionId !== session.sessionId) {
session.onSessionFound(clientSessionId);
}
};
await client.connect();
if (useAppServer && appServerClient) {
await appServerClient.connect();
await appServerClient.initialize({
clientInfo: {
name: 'hapi-codex-client',
version: '1.0.0'
}
});
} else if (mcpClient) {
await mcpClient.connect();
}
let wasCreated = false;
let currentModeHash: string | null = null;
let pending: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = null;
let nextExperimentalResume: string | null = null;
let first = true;
let turnInFlight = false;
while (!this.shouldExit) {
logActiveHandles('loop-top');
@@ -556,23 +516,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
break;
}
if (wasCreated && currentModeHash && message.hash !== currentModeHash) {
if (!useAppServer && wasCreated && currentModeHash && message.hash !== currentModeHash) {
logger.debug('[Codex] Mode changed restarting Codex session');
messageBuffer.addMessage('═'.repeat(40), 'status');
messageBuffer.addMessage('Starting new Codex session (mode changed)...', 'status');
try {
const prevSessionId = client.getSessionId();
nextExperimentalResume = findCodexResumeFile(prevSessionId);
if (nextExperimentalResume) {
logger.debug(`[Codex] Found resume file for session ${prevSessionId}: ${nextExperimentalResume}`);
messageBuffer.addMessage('Resuming previous context…', 'status');
} else {
logger.debug('[Codex] No resume file found for previous session');
}
} catch (error) {
logger.debug('[Codex] Error while searching resume file', error);
}
client.clearSession();
mcpClient?.clearSession();
wasCreated = false;
currentModeHash = null;
pending = message;
@@ -588,81 +536,149 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
try {
if (!wasCreated) {
let resumeFile: string | null = null;
if (nextExperimentalResume) {
resumeFile = nextExperimentalResume;
nextExperimentalResume = null;
logger.debug('[Codex] Using resume file from mode change:', resumeFile);
} else if (this.storedSessionIdForResume) {
const abortResumeFile = findCodexResumeFile(this.storedSessionIdForResume);
if (abortResumeFile) {
resumeFile = abortResumeFile;
logger.debug('[Codex] Using resume file from aborted session:', resumeFile);
messageBuffer.addMessage('Resuming from aborted session...', 'status');
if (useAppServer && appServerClient) {
const threadParams = buildThreadStartParams({
mode: message.mode,
mcpServers,
cliOverrides: session.codexCliOverrides
});
const resumeCandidate = session.sessionId;
let threadId: string | null = null;
if (resumeCandidate) {
try {
const resumeResponse = await appServerClient.resumeThread({
threadId: resumeCandidate,
...threadParams
}, {
signal: this.abortController.signal
});
const resumeRecord = asRecord(resumeResponse);
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
threadId = asString(resumeThread?.id) ?? resumeCandidate;
logger.debug(`[Codex] Resumed app-server thread ${threadId}`);
} catch (error) {
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}, starting new thread`, error);
}
}
this.storedSessionIdForResume = null;
} else if (first && session.sessionId) {
const localResumeFile = findCodexResumeFile(session.sessionId);
if (localResumeFile) {
resumeFile = localResumeFile;
logger.debug('[Codex] Using resume file from local session:', localResumeFile);
messageBuffer.addMessage('Resuming from local session log...', 'status');
if (!threadId) {
const threadResponse = await appServerClient.startThread(threadParams, {
signal: this.abortController.signal
});
const threadRecord = asRecord(threadResponse);
const thread = threadRecord ? asRecord(threadRecord.thread) : null;
threadId = asString(thread?.id);
if (!threadId) {
throw new Error('app-server thread/start did not return thread.id');
}
}
if (!threadId) {
throw new Error('app-server resume did not return thread.id');
}
this.currentThreadId = threadId;
session.onSessionFound(threadId);
const turnParams = buildTurnStartParams({
threadId,
message: message.message,
mode: message.mode,
cliOverrides: session.codexCliOverrides
});
turnInFlight = true;
const turnResponse = await appServerClient.startTurn(turnParams, {
signal: this.abortController.signal
});
const turnRecord = asRecord(turnResponse);
const turn = turnRecord ? asRecord(turnRecord.turn) : null;
const turnId = asString(turn?.id);
if (turnId) {
this.currentTurnId = turnId;
}
} else if (mcpClient) {
const startConfig: CodexSessionConfig = buildCodexStartConfig({
message: message.message,
mode: message.mode,
first,
mcpServers,
cliOverrides: session.codexCliOverrides
});
await mcpClient.startSession(startConfig, { signal: this.abortController.signal });
syncSessionId();
}
const developerInstructions = resumeFile
? buildResumeInstructionsFromFile(resumeFile)
: undefined;
const startConfig: CodexSessionConfig = buildCodexStartConfig({
message: message.message,
mode: message.mode,
first,
mcpServers,
cliOverrides: session.codexCliOverrides,
developerInstructions
});
if (resumeFile) {
(startConfig.config as any).experimental_resume = resumeFile;
}
await client.startSession(startConfig, { signal: this.abortController.signal });
wasCreated = true;
first = false;
syncSessionId();
} else {
await client.continueSession(message.message, { signal: this.abortController.signal });
} else if (useAppServer && appServerClient) {
if (!this.currentThreadId) {
logger.debug('[Codex] Missing thread id; restarting app-server thread');
wasCreated = false;
pending = message;
continue;
}
const turnParams = buildTurnStartParams({
threadId: this.currentThreadId,
message: message.message,
mode: message.mode,
cliOverrides: session.codexCliOverrides
});
turnInFlight = true;
const turnResponse = await appServerClient.startTurn(turnParams, {
signal: this.abortController.signal
});
const turnRecord = asRecord(turnResponse);
const turn = turnRecord ? asRecord(turnRecord.turn) : null;
const turnId = asString(turn?.id);
if (turnId) {
this.currentTurnId = turnId;
}
} else if (mcpClient) {
await mcpClient.continueSession(message.message, { signal: this.abortController.signal });
syncSessionId();
}
} catch (error) {
logger.warn('Error in codex session:', error);
const isAbortError = error instanceof Error && error.name === 'AbortError';
if (useAppServer) {
turnInFlight = false;
}
if (isAbortError) {
messageBuffer.addMessage('Aborted by user', 'status');
session.sendSessionEvent({ type: 'message', message: 'Aborted by user' });
wasCreated = false;
currentModeHash = null;
logger.debug('[Codex] Marked session as not created after abort for proper resume');
if (!useAppServer) {
wasCreated = false;
currentModeHash = null;
logger.debug('[Codex] Marked session as not created after abort for proper resume');
}
} else {
messageBuffer.addMessage('Process exited unexpectedly', 'status');
session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' });
if (client.hasActiveSession()) {
this.storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session after unexpected error:', this.storedSessionIdForResume);
if (useAppServer) {
this.currentTurnId = null;
this.currentThreadId = null;
wasCreated = false;
}
}
} finally {
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
appServerEventConverter?.reset();
session.onThinkingChange(false);
emitReadyIfIdle({
pending,
queueSize: () => session.queue.size(),
shouldExit: this.shouldExit,
sendReady
});
if (!useAppServer || !turnInFlight) {
emitReadyIfIdle({
pending,
queueSize: () => session.queue.size(),
shouldExit: this.shouldExit,
sendReady
});
}
logActiveHandles('after-turn');
}
}
@@ -671,7 +687,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
protected async cleanup(): Promise<void> {
logger.debug('[codex-remote]: cleanup start');
try {
await this.client.disconnect();
if (this.appServerClient) {
await this.appServerClient.disconnect();
}
if (this.mcpClient) {
await this.mcpClient.disconnect();
}
} catch (error) {
logger.debug('[codex-remote]: Error disconnecting client', error);
}