refactor: extract remote launcher base class for code reuse

This commit is contained in:
weishu
2026-01-05 16:50:22 +08:00
parent 0e360f0a48
commit e8252c601f
3 changed files with 918 additions and 815 deletions
+452 -438
View File
@@ -1,4 +1,3 @@
import { render } from 'ink';
import React from 'react';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
@@ -10,7 +9,6 @@ import { CodexPermissionHandler } from './utils/permissionHandler';
import { ReasoningProcessor } from './utils/reasoningProcessor';
import { DiffProcessor } from './utils/diffProcessor';
import { logger } from '@/ui/logger';
import { MessageBuffer } from '@/ui/ink/messageBuffer';
import { CodexDisplay } from '@/ui/ink/CodexDisplay';
import type { CodexSessionConfig } from './types';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
@@ -18,437 +16,448 @@ import { startHappyServer } from '@/claude/utils/startHappyServer';
import { emitReadyIfIdle } from './utils/emitReadyIfIdle';
import type { CodexSession } from './session';
import type { EnhancedMode } from './loop';
import { restoreTerminalState } from '@/ui/terminalState';
import { hasCodexCliOverrides } from './utils/codexCliOverrides';
import { buildCodexStartConfig } from './utils/codexStartConfig';
import { convertCodexEvent } from './utils/codexEventConverter';
import {
RemoteLauncherBase,
type RemoteLauncherDisplayContext,
type RemoteLauncherExitReason
} from '@/modules/common/remote/RemoteLauncherBase';
export async function codexRemoteLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
// Warn if CLI args were passed that won't apply in remote mode
if (session.codexArgs && session.codexArgs.length > 0) {
if (hasCodexCliOverrides(session.codexCliOverrides)) {
logger.debug(`[codex-remote] CLI args include sandbox/approval overrides; other args ` +
`are ignored in remote mode.`);
} else {
logger.debug(`[codex-remote] Warning: CLI args [${session.codexArgs.join(', ')}] are ignored in remote mode. ` +
`Remote mode uses message-based configuration (model/sandbox set via web interface).`);
}
type HappyServer = Awaited<ReturnType<typeof startHappyServer>>;
class CodexRemoteLauncher extends RemoteLauncherBase {
private readonly session: CodexSession;
private readonly client: CodexMcpClient;
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;
constructor(session: CodexSession) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
this.client = new CodexMcpClient();
}
const hasTTY = process.stdout.isTTY && process.stdin.isTTY;
const messageBuffer = new MessageBuffer();
let inkInstance: any = null;
let exitReason: 'switch' | 'exit' | null = null;
let shouldExit = false;
if (hasTTY) {
console.clear();
inkInstance = render(React.createElement(CodexDisplay, {
messageBuffer,
logPath: process.env.DEBUG ? session.logPath : undefined,
onExit: async () => {
logger.debug('[codex-remote]: Exiting agent via Ctrl-C');
exitReason = 'exit';
shouldExit = true;
await handleAbort();
},
onSwitchToLocal: async () => {
logger.debug('[codex-remote]: Switching to local mode via double space');
exitReason = 'switch';
shouldExit = true;
await handleAbort();
}
}), {
exitOnCtrlC: false,
patchConsole: false
});
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
return React.createElement(CodexDisplay, context);
}
if (hasTTY) {
process.stdin.resume();
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding('utf8');
}
const client = new CodexMcpClient();
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;
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);
return null;
}
}
function safeStringify(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value);
} catch {
return null;
}
}
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);
const reasoningProcessor = new ReasoningProcessor((message) => {
session.sendCodexMessage(message);
});
const diffProcessor = new DiffProcessor((message) => {
session.sendCodexMessage(message);
});
client.setPermissionHandler(permissionHandler);
client.setHandler((msg) => {
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 (!session.thinking) {
logger.debug('thinking started');
session.onThinkingChange(true);
}
}
if (msg.type === 'task_complete' || msg.type === 'turn_aborted') {
if (session.thinking) {
logger.debug('thinking completed');
session.onThinkingChange(false);
}
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: 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 (msg.type === 'exec_command_end') {
const { call_id, type, ...output } = msg;
session.sendCodexMessage({
type: 'tool-call-result',
callId: call_id,
output: output,
id: randomUUID()
});
}
if (msg.type === 'token_count') {
session.sendCodexMessage({
...msg,
id: randomUUID()
});
}
if (msg.type === 'patch_apply_begin') {
const { 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: 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);
}
}
});
const happyServer = await startHappyServer(session.client);
const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]);
const mcpServers = {
hapi: {
command: bridgeCommand.command,
args: bridgeCommand.args
}
} as const;
let abortController = new AbortController();
let storedSessionIdForResume: string | null = null;
async function handleAbort() {
private async handleAbort(): Promise<void> {
logger.debug('[Codex] Abort requested - stopping current task');
try {
if (client.hasActiveSession()) {
storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session for resume:', storedSessionIdForResume);
if (this.client.hasActiveSession()) {
this.storedSessionIdForResume = this.client.storeSessionForResume();
logger.debug('[Codex] Stored session for resume:', this.storedSessionIdForResume);
}
abortController.abort();
session.queue.reset();
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
this.abortController.abort();
this.session.queue.reset();
this.permissionHandler?.reset();
this.reasoningProcessor?.abort();
this.diffProcessor?.reset();
logger.debug('[Codex] Abort completed - session remains active');
} catch (error) {
logger.debug('[Codex] Error during abort:', error);
} finally {
abortController = new AbortController();
this.abortController = new AbortController();
}
}
session.client.rpcHandlerManager.registerHandler('abort', async () => {
await handleAbort();
});
session.client.rpcHandlerManager.registerHandler('switch', async () => {
exitReason = 'switch';
shouldExit = true;
await handleAbort();
});
function logActiveHandles(tag: string) {
if (!process.env.DEBUG) return;
const anyProc: any = process as any;
const handles = typeof anyProc._getActiveHandles === 'function' ? anyProc._getActiveHandles() : [];
const requests = typeof anyProc._getActiveRequests === 'function' ? anyProc._getActiveRequests() : [];
logger.debug(`[codex][handles] ${tag}: handles=${handles.length} requests=${requests.length}`);
try {
const kinds = handles.map((h: any) => (h && h.constructor ? h.constructor.name : typeof h));
logger.debug(`[codex][handles] kinds=${JSON.stringify(kinds)}`);
} catch {}
private async handleExitFromUi(): Promise<void> {
logger.debug('[codex-remote]: Exiting agent via Ctrl-C');
this.exitReason = 'exit';
this.shouldExit = true;
await this.handleAbort();
}
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
};
private async handleSwitchFromUi(): Promise<void> {
logger.debug('[codex-remote]: Switching to local mode via double space');
this.exitReason = 'switch';
this.shouldExit = true;
await this.handleAbort();
}
const syncSessionId = () => {
const clientSessionId = client.getSessionId();
if (clientSessionId && clientSessionId !== session.sessionId) {
session.onSessionFound(clientSessionId);
private async handleSwitchRequest(): Promise<void> {
this.exitReason = 'switch';
this.shouldExit = true;
await this.handleAbort();
}
public async launch(): Promise<RemoteLauncherExitReason> {
if (this.session.codexArgs && this.session.codexArgs.length > 0) {
if (hasCodexCliOverrides(this.session.codexCliOverrides)) {
logger.debug(`[codex-remote] CLI args include sandbox/approval overrides; other args ` +
`are ignored in remote mode.`);
} else {
logger.debug(`[codex-remote] Warning: CLI args [${this.session.codexArgs.join(', ')}] are ignored in remote mode. ` +
`Remote mode uses message-based configuration (model/sandbox set via web interface).`);
}
}
};
try {
return this.start({
onExit: () => this.handleExitFromUi(),
onSwitchToLocal: () => this.handleSwitchFromUi()
});
}
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;
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);
return null;
}
}
function safeStringify(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value);
} catch {
return null;
}
}
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);
const reasoningProcessor = new ReasoningProcessor((message) => {
session.sendCodexMessage(message);
});
const diffProcessor = new DiffProcessor((message) => {
session.sendCodexMessage(message);
});
this.permissionHandler = permissionHandler;
this.reasoningProcessor = reasoningProcessor;
this.diffProcessor = diffProcessor;
client.setPermissionHandler(permissionHandler);
client.setHandler((msg) => {
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 (!session.thinking) {
logger.debug('thinking started');
session.onThinkingChange(true);
}
}
if (msg.type === 'task_complete' || msg.type === 'turn_aborted') {
if (session.thinking) {
logger.debug('thinking completed');
session.onThinkingChange(false);
}
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: 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 (msg.type === 'exec_command_end') {
const { call_id, type, ...output } = msg;
session.sendCodexMessage({
type: 'tool-call-result',
callId: call_id,
output: output,
id: randomUUID()
});
}
if (msg.type === 'token_count') {
session.sendCodexMessage({
...msg,
id: randomUUID()
});
}
if (msg.type === 'patch_apply_begin') {
const { 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: 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);
}
}
});
const happyServer = await startHappyServer(session.client);
this.happyServer = happyServer;
const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]);
const mcpServers = {
hapi: {
command: bridgeCommand.command,
args: bridgeCommand.args
}
} as const;
this.setupAbortHandlers(session.client.rpcHandlerManager, {
onAbort: () => this.handleAbort(),
onSwitch: () => this.handleSwitchRequest()
});
function logActiveHandles(tag: string) {
if (!process.env.DEBUG) return;
const anyProc: any = process as any;
const handles = typeof anyProc._getActiveHandles === 'function' ? anyProc._getActiveHandles() : [];
const requests = typeof anyProc._getActiveRequests === 'function' ? anyProc._getActiveRequests() : [];
logger.debug(`[codex][handles] ${tag}: handles=${handles.length} requests=${requests.length}`);
try {
const kinds = handles.map((h: any) => (h && h.constructor ? h.constructor.name : typeof h));
logger.debug(`[codex][handles] kinds=${JSON.stringify(kinds)}`);
} catch {}
}
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
};
const syncSessionId = () => {
const clientSessionId = client.getSessionId();
if (clientSessionId && clientSessionId !== session.sessionId) {
session.onSessionFound(clientSessionId);
}
};
await client.connect();
let wasCreated = false;
@@ -457,19 +466,19 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc
let nextExperimentalResume: string | null = null;
let first = true;
while (!shouldExit) {
while (!this.shouldExit) {
logActiveHandles('loop-top');
let message: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = pending;
pending = null;
if (!message) {
const waitSignal = abortController.signal;
const waitSignal = this.abortController.signal;
const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal);
if (!batch) {
if (waitSignal.aborted && !shouldExit) {
if (waitSignal.aborted && !this.shouldExit) {
logger.debug('[codex]: Wait aborted while idle; ignoring and continuing');
continue;
}
logger.debug(`[codex]: batch=${!!batch}, shouldExit=${shouldExit}`);
logger.debug(`[codex]: batch=${!!batch}, shouldExit=${this.shouldExit}`);
break;
}
message = batch;
@@ -516,19 +525,19 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc
resumeFile = nextExperimentalResume;
nextExperimentalResume = null;
logger.debug('[Codex] Using resume file from mode change:', resumeFile);
} else if (storedSessionIdForResume) {
const abortResumeFile = findCodexResumeFile(storedSessionIdForResume);
} 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');
}
storedSessionIdForResume = null;
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:', resumeFile);
logger.debug('[Codex] Using resume file from local session:', localResumeFile);
messageBuffer.addMessage('Resuming from local session log...', 'status');
}
}
@@ -549,12 +558,12 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc
(startConfig.config as any).experimental_resume = resumeFile;
}
await client.startSession(startConfig, { signal: abortController.signal });
await client.startSession(startConfig, { signal: this.abortController.signal });
wasCreated = true;
first = false;
syncSessionId();
} else {
await client.continueSession(message.message, { signal: abortController.signal });
await client.continueSession(message.message, { signal: this.abortController.signal });
syncSessionId();
}
} catch (error) {
@@ -571,8 +580,8 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc
messageBuffer.addMessage('Process exited unexpectedly', 'status');
session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' });
if (client.hasActiveSession()) {
storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session after unexpected error:', storedSessionIdForResume);
this.storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session after unexpected error:', this.storedSessionIdForResume);
}
}
} finally {
@@ -583,36 +592,41 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc
emitReadyIfIdle({
pending,
queueSize: () => session.queue.size(),
shouldExit,
shouldExit: this.shouldExit,
sendReady
});
logActiveHandles('after-turn');
}
}
} finally {
}
protected async cleanup(): Promise<void> {
logger.debug('[codex-remote]: cleanup start');
try {
await client.disconnect();
await this.client.disconnect();
} catch (error) {
logger.debug('[codex-remote]: Error disconnecting client', error);
}
session.client.rpcHandlerManager.registerHandler('abort', async () => {});
session.client.rpcHandlerManager.registerHandler('switch', async () => {});
happyServer.stop();
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
restoreTerminalState();
if (hasTTY) {
try { process.stdin.pause(); } catch {}
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
if (this.happyServer) {
this.happyServer.stop();
this.happyServer = null;
}
if (inkInstance) {
inkInstance.unmount();
}
messageBuffer.clear();
this.permissionHandler?.reset();
this.reasoningProcessor?.abort();
this.diffProcessor?.reset();
this.permissionHandler = null;
this.reasoningProcessor = null;
this.diffProcessor = null;
logger.debug('[codex-remote]: cleanup done');
}
}
return exitReason || 'exit';
export async function codexRemoteLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
const launcher = new CodexRemoteLauncher(session);
return launcher.launch();
}