refactor(codex): extract session management and launcher logic into separate modules

Reorganized runCodex.ts to improve maintainability by extracting:
- CodexSession class for session lifecycle management
- CodexLocalLauncher and CodexRemoteLauncher for mode-specific initialization
- CodexEventConverter for MCP message handling and UI buffer updates
- CodexSessionScanner for resume file discovery
- emitReadyIfIdle utility for ready event emission

Added codexSessionId field to metadata schema for session tracking.
Updated UI components to work with refactored architecture.
This commit is contained in:
weishu
2025-12-22 19:14:47 +08:00
parent 0c16b7b215
commit 4290f539bd
15 changed files with 1606 additions and 661 deletions
+95
View File
@@ -0,0 +1,95 @@
import { spawn } from 'node:child_process';
import { logger } from '@/ui/logger';
export async function codexLocal(opts: {
abort: AbortSignal;
sessionId: string | null;
path: string;
model?: string;
sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
onSessionFound: (id: string) => void;
}): Promise<void> {
const args: string[] = [];
if (opts.sessionId) {
args.push('resume', opts.sessionId);
opts.onSessionFound(opts.sessionId);
}
if (opts.model) {
args.push('--model', opts.model);
}
if (opts.sandbox) {
args.push('--sandbox', opts.sandbox);
}
logger.debug(`[CodexLocal] Spawning codex with args: ${JSON.stringify(args)}`);
process.stdin.pause();
try {
await new Promise<void>((resolve, reject) => {
const child = spawn('codex', args, {
stdio: ['inherit', 'inherit', 'inherit'],
signal: opts.abort,
cwd: opts.path,
env: process.env
});
let abortKillTimeout: NodeJS.Timeout | null = null;
const abortHandler = () => {
if (abortKillTimeout) {
return;
}
abortKillTimeout = setTimeout(() => {
if (child.exitCode === null && !child.killed) {
logger.debug('[CodexLocal] Abort timeout reached, sending SIGKILL');
try {
child.kill('SIGKILL');
} catch (error) {
logger.debug('[CodexLocal] Failed to send SIGKILL:', error);
}
}
}, 5000);
};
if (opts.abort.aborted) {
abortHandler();
} else {
opts.abort.addEventListener('abort', abortHandler);
}
const cleanupAbortHandler = () => {
if (abortKillTimeout) {
clearTimeout(abortKillTimeout);
abortKillTimeout = null;
}
opts.abort.removeEventListener('abort', abortHandler);
};
child.on('error', (error) => {
cleanupAbortHandler();
reject(error);
});
child.on('exit', (code, signal) => {
cleanupAbortHandler();
if (signal === 'SIGTERM' && opts.abort.aborted) {
resolve();
return;
}
if (signal) {
reject(new Error(`Process terminated with signal: ${signal}`));
return;
}
if (typeof code === 'number' && code !== 0) {
reject(new Error(`Process exited with code: ${code}`));
return;
}
resolve();
});
});
} finally {
process.stdin.resume();
}
}
+107
View File
@@ -0,0 +1,107 @@
import { logger } from '@/ui/logger';
import { codexLocal } from './codexLocal';
import { CodexSession } from './session';
import { Future } from '@/utils/future';
import { createCodexSessionScanner } from './utils/codexSessionScanner';
import { convertCodexEvent } from './utils/codexEventConverter';
export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
const scanner = await createCodexSessionScanner({
sessionId: session.sessionId,
onSessionFound: (sessionId) => {
session.onSessionFound(sessionId);
},
onEvent: (event) => {
const converted = convertCodexEvent(event);
if (converted?.sessionId) {
session.onSessionFound(converted.sessionId);
scanner.onNewSession(converted.sessionId);
}
if (converted?.message) {
session.sendCodexMessage(converted.message);
}
}
});
let exitReason: 'switch' | 'exit' | null = null;
const processAbortController = new AbortController();
const exitFuture = new Future<void>();
try {
async function abortProcess() {
if (!processAbortController.signal.aborted) {
processAbortController.abort();
}
await exitFuture.promise;
}
async function doAbort() {
logger.debug('[codex-local]: doAbort');
if (!exitReason) {
exitReason = 'switch';
}
session.queue.reset();
await abortProcess();
}
async function doSwitch() {
logger.debug('[codex-local]: doSwitch');
if (!exitReason) {
exitReason = 'switch';
}
await abortProcess();
}
session.client.rpcHandlerManager.registerHandler('abort', doAbort);
session.client.rpcHandlerManager.registerHandler('switch', doSwitch);
session.queue.setOnMessage(() => {
void doSwitch();
});
if (session.queue.size() > 0) {
return 'switch';
}
const handleSessionFound = (sessionId: string) => {
session.onSessionFound(sessionId);
scanner.onNewSession(sessionId);
};
while (true) {
if (exitReason) {
return exitReason;
}
logger.debug('[codex-local]: launch');
try {
await codexLocal({
path: session.path,
sessionId: session.sessionId,
onSessionFound: handleSessionFound,
abort: processAbortController.signal
});
if (!exitReason) {
exitReason = 'exit';
break;
}
} catch (error) {
logger.debug('[codex-local]: launch error', error);
const message = error instanceof Error ? error.message : String(error);
session.sendSessionEvent({ type: 'message', message: `Local Codex process failed: ${message}` });
if (!exitReason) {
exitReason = 'switch';
}
break;
}
}
} finally {
exitFuture.resolve(undefined);
session.client.rpcHandlerManager.registerHandler('abort', async () => {});
session.client.rpcHandlerManager.registerHandler('switch', async () => {});
session.queue.setOnMessage(null);
await scanner.cleanup();
}
return exitReason || 'exit';
}
+484
View File
@@ -0,0 +1,484 @@
import { render } from 'ink';
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 { 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 { trimIdent } from '@/utils/trimIdent';
import type { CodexSessionConfig } from './types';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import { startHappyServer } from '@/claude/utils/startHappyServer';
import { emitReadyIfIdle } from './utils/emitReadyIfIdle';
import type { CodexSession } from './session';
import type { EnhancedMode } from './loop';
export async function codexRemoteLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
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
});
}
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 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() {
logger.debug('[Codex] Abort requested - stopping current task');
try {
if (client.hasActiveSession()) {
storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session for resume:', storedSessionIdForResume);
}
abortController.abort();
session.queue.reset();
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
logger.debug('[Codex] Abort completed - session remains active');
} catch (error) {
logger.debug('[Codex] Error during abort:', error);
} finally {
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 {}
}
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
};
const syncSessionId = () => {
const clientSessionId = client.getSessionId();
if (clientSessionId && clientSessionId !== session.sessionId) {
session.onSessionFound(clientSessionId);
}
};
try {
await client.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;
while (!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 batch = await session.queue.waitForMessagesAndGetAsString(waitSignal);
if (!batch) {
if (waitSignal.aborted && !shouldExit) {
logger.debug('[codex]: Wait aborted while idle; ignoring and continuing');
continue;
}
logger.debug(`[codex]: batch=${!!batch}, shouldExit=${shouldExit}`);
break;
}
message = batch;
}
if (!message) {
break;
}
if (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();
wasCreated = false;
currentModeHash = null;
pending = message;
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
session.onThinkingChange(false);
continue;
}
messageBuffer.addMessage(message.message, 'user');
currentModeHash = message.hash;
try {
const approvalPolicy = (() => {
switch (message.mode.permissionMode) {
case 'default': return 'untrusted' as const;
case 'read-only': return 'never' as const;
case 'safe-yolo': return 'on-failure' as const;
case 'yolo': return 'on-failure' as const;
}
})();
const sandbox = (() => {
switch (message.mode.permissionMode) {
case 'default': return 'workspace-write' as const;
case 'read-only': return 'read-only' as const;
case 'safe-yolo': return 'workspace-write' as const;
case 'yolo': return 'danger-full-access' as const;
}
})();
if (!wasCreated) {
const startConfig: CodexSessionConfig = {
prompt: first ? message.message + '\n\n' + trimIdent(`Based on this message, call functions.hapi__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.`) : message.message,
sandbox,
'approval-policy': approvalPolicy,
config: { mcp_servers: mcpServers }
};
if (message.mode.model) {
startConfig.model = message.mode.model;
}
let resumeFile: string | null = null;
if (nextExperimentalResume) {
resumeFile = nextExperimentalResume;
nextExperimentalResume = null;
logger.debug('[Codex] Using resume file from mode change:', resumeFile);
} else if (storedSessionIdForResume) {
const abortResumeFile = findCodexResumeFile(storedSessionIdForResume);
if (abortResumeFile) {
resumeFile = abortResumeFile;
logger.debug('[Codex] Using resume file from aborted session:', resumeFile);
messageBuffer.addMessage('Resuming from aborted session...', 'status');
}
storedSessionIdForResume = null;
}
if (resumeFile) {
(startConfig.config as any).experimental_resume = resumeFile;
}
await client.startSession(startConfig, { signal: abortController.signal });
wasCreated = true;
first = false;
syncSessionId();
} else {
await client.continueSession(message.message, { signal: abortController.signal });
syncSessionId();
}
} catch (error) {
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' });
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()) {
storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session after unexpected error:', storedSessionIdForResume);
}
}
} finally {
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
session.onThinkingChange(false);
emitReadyIfIdle({
pending,
queueSize: () => session.queue.size(),
shouldExit,
sendReady
});
logActiveHandles('after-turn');
}
}
} finally {
logger.debug('[codex-remote]: cleanup start');
try {
await 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();
if (process.stdin.isTTY) {
try { process.stdin.setRawMode(false); } catch {}
}
if (hasTTY) {
try { process.stdin.pause(); } catch {}
}
if (inkInstance) {
inkInstance.unmount();
}
messageBuffer.clear();
logger.debug('[codex-remote]: cleanup done');
}
return exitReason || 'exit';
}
+67
View File
@@ -0,0 +1,67 @@
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger';
import { CodexSession } from './session';
import { codexLocalLauncher } from './codexLocalLauncher';
import { codexRemoteLauncher } from './codexRemoteLauncher';
import { ApiClient, ApiSessionClient } from '@/lib';
export type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo';
export interface EnhancedMode {
permissionMode: PermissionMode;
model?: string;
}
interface LoopOptions {
path: string;
startingMode?: 'local' | 'remote';
onModeChange: (mode: 'local' | 'remote') => void;
messageQueue: MessageQueue2<EnhancedMode>;
session: ApiSessionClient;
api: ApiClient;
onSessionReady?: (session: CodexSession) => void;
}
export async function loop(opts: LoopOptions): Promise<void> {
const logPath = logger.getLogPath();
const session = new CodexSession({
api: opts.api,
client: opts.session,
path: opts.path,
sessionId: null,
logPath,
messageQueue: opts.messageQueue,
onModeChange: opts.onModeChange,
mode: opts.startingMode ?? 'local'
});
if (opts.onSessionReady) {
opts.onSessionReady(session);
}
let mode: 'local' | 'remote' = opts.startingMode ?? 'local';
while (true) {
logger.debug(`[codex-loop] Iteration with mode: ${mode}`);
if (mode === 'local') {
const reason = await codexLocalLauncher(session);
if (reason === 'exit') {
return;
}
mode = 'remote';
session.onModeChange(mode);
continue;
}
if (mode === 'remote') {
const reason = await codexRemoteLauncher(session);
if (reason === 'exit') {
return;
}
mode = 'local';
session.onModeChange(mode);
continue;
}
}
}
+93 -642
View File
@@ -1,108 +1,53 @@
import { render } from "ink";
import React from "react";
import { ApiClient } from '@/api/api';
import { CodexMcpClient } from './codexMcpClient';
import { CodexPermissionHandler } from './utils/permissionHandler';
import { ReasoningProcessor } from './utils/reasoningProcessor';
import { DiffProcessor } from './utils/diffProcessor';
import { randomUUID } from 'node:crypto';
import { logger } from '@/ui/logger';
import { readSettings } from '@/persistence';
import { AgentState, Metadata } from '@/api/types';
import { initialMachineMetadata } from '@/daemon/run';
import { configuration } from '@/configuration';
import packageJson from '../../package.json';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { ApiClient } from '@/api/api';
import { logger } from '@/ui/logger';
import { loop, type EnhancedMode, type PermissionMode } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
import { readSettings } from '@/persistence';
import { configuration } from '@/configuration';
import { notifyDaemonSessionStarted } from '@/daemon/controlClient';
import { initialMachineMetadata } from '@/daemon/run';
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
import type { AgentState, Metadata } from '@/api/types';
import packageJson from '../../package.json';
import { runtimePath } from '@/projectPath';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import { resolve, join } from 'node:path';
import fs from 'node:fs';
import { startHappyServer } from '@/claude/utils/startHappyServer';
import { MessageBuffer } from "@/ui/ink/messageBuffer";
import { CodexDisplay } from "@/ui/ink/CodexDisplay";
import { trimIdent } from "@/utils/trimIdent";
import type { CodexSessionConfig } from './types';
import { notifyDaemonSessionStarted } from "@/daemon/controlClient";
import { registerKillSessionHandler } from "@/claude/registerKillSessionHandler";
import { delay } from "@/utils/time";
import type { CodexSession } from './session';
type ReadyEventOptions = {
pending: unknown;
queueSize: () => number;
shouldExit: boolean;
sendReady: () => void;
notify?: () => void;
};
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
/**
* Notify connected clients when Codex finishes processing and the queue is idle.
* Returns true when a ready event was emitted.
*/
export function emitReadyIfIdle({ pending, queueSize, shouldExit, sendReady, notify }: ReadyEventOptions): boolean {
if (shouldExit) {
return false;
}
if (pending) {
return false;
}
if (queueSize() > 0) {
return false;
}
sendReady();
notify?.();
return true;
}
/**
* Main entry point for the codex command with ink UI
*/
export async function runCodex(opts: {
startedBy?: 'daemon' | 'terminal';
}): Promise<void> {
type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo';
interface EnhancedMode {
permissionMode: PermissionMode;
model?: string;
}
//
// Define session
//
const workingDirectory = process.cwd();
const sessionTag = randomUUID();
const api = await ApiClient.create();
// Log startup options
logger.debug(`[codex] Starting with options: startedBy=${opts.startedBy || 'terminal'}`);
//
// Machine
//
const api = await ApiClient.create();
const settings = await readSettings();
let machineId = settings?.machineId;
const 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 ${packageJson.bugs}`);
process.exit(1);
}
logger.debug(`Using machineId: ${machineId}`);
await api.getOrCreateMachine({
machineId,
metadata: initialMachineMetadata
});
//
// Create session
//
let state: AgentState = {
controlledByUser: false,
}
let metadata: Metadata = {
path: process.cwd(),
controlledByUser: false
};
const metadata: Metadata = {
path: workingDirectory,
host: os.hostname(),
version: packageJson.version,
os: os.platform(),
@@ -114,15 +59,14 @@ export async function runCodex(opts: {
startedFromDaemon: opts.startedBy === 'daemon',
hostPid: process.pid,
startedBy: opts.startedBy || 'terminal',
// Initialize lifecycle state
lifecycleState: 'running',
lifecycleStateSince: Date.now(),
flavor: 'codex'
};
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
const session = api.sessionSyncClient(response);
// Always report to daemon if it exists
try {
logger.debug(`[START] Reporting session ${response.id} to daemon`);
const result = await notifyDaemonSessionStarted(response.id, metadata);
@@ -135,17 +79,22 @@ export async function runCodex(opts: {
logger.debug('[START] Failed to report to daemon (may not be running):', error);
}
const startingMode: 'local' | 'remote' = opts.startedBy === 'daemon' ? 'remote' : 'local';
session.updateAgentState((currentState) => ({
...currentState,
controlledByUser: startingMode === 'local'
}));
const messageQueue = new MessageQueue2<EnhancedMode>((mode) => hashObject({
permissionMode: mode.permissionMode,
model: mode.model,
model: mode.model
}));
// Track current overrides to apply per message
let currentPermissionMode: PermissionMode | undefined = undefined;
let currentModel: string | undefined = undefined;
session.onUserMessage((message) => {
// Resolve permission mode (validate)
let messagePermissionMode = currentPermissionMode;
if (message.meta?.permissionMode) {
const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo'];
@@ -160,7 +109,6 @@ export async function runCodex(opts: {
logger.debug(`[Codex] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`);
}
// Resolve model; explicit null resets to default (undefined)
let messageModel = currentModel;
if (message.meta?.hasOwnProperty('model')) {
messageModel = message.meta.model || undefined;
@@ -172,585 +120,88 @@ export async function runCodex(opts: {
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode || 'default',
model: messageModel,
model: messageModel
};
messageQueue.push(message.content.text, enhancedMode);
});
let thinking = false;
session.keepAlive(thinking, 'remote');
// Periodic keep-alive; store handle so we can clear on exit
const keepAliveInterval = setInterval(() => {
session.keepAlive(thinking, 'remote');
}, 2000);
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
};
let sessionWrapper: CodexSession | null = null;
// Debug helper: log active handles/requests if DEBUG is enabled
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 { }
}
let cleanupStarted = false;
let exitCode = 0;
//
// Abort handling
// IMPORTANT: There are two different operations:
// 1. Abort (handleAbort): Stops the current inference/task but keeps the session alive
// - Used by the 'abort' RPC from mobile app
// - Similar to Claude Code's abort behavior
// - Allows continuing with new prompts after aborting
// 2. Kill (handleKillSession): Terminates the entire process
// - Used by the 'killSession' RPC
// - Completely exits the CLI process
//
let abortController = new AbortController();
let shouldExit = false;
let storedSessionIdForResume: string | null = null;
/**
* Handles aborting the current task/inference without exiting the process.
* This is the equivalent of Claude Code's abort - it stops what's currently
* happening but keeps the session alive for new prompts.
*/
async function handleAbort() {
logger.debug('[Codex] Abort requested - stopping current task');
try {
// Store the current session ID before aborting for potential resume
if (client.hasActiveSession()) {
storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session for resume:', storedSessionIdForResume);
}
abortController.abort();
messageQueue.reset();
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
logger.debug('[Codex] Abort completed - session remains active');
} catch (error) {
logger.debug('[Codex] Error during abort:', error);
} finally {
abortController = new AbortController();
const cleanup = async (code: number = exitCode) => {
if (cleanupStarted) {
return;
}
}
/**
* Handles session termination and process exit.
* This is called when the session needs to be completely killed (not just aborted).
* Abort stops the current inference but keeps the session alive.
* Kill terminates the entire process.
*/
const handleKillSession = async () => {
logger.debug('[Codex] Kill session requested - terminating process');
await handleAbort();
logger.debug('[Codex] Abort completed, proceeding with termination');
cleanupStarted = true;
logger.debug('[codex] Cleanup start');
try {
// Update lifecycle state to archived before closing
if (session) {
session.updateMetadata((currentMetadata) => ({
...currentMetadata,
lifecycleState: 'archived',
lifecycleStateSince: Date.now(),
archivedBy: 'cli',
archiveReason: 'User terminated'
}));
// Send session death message
session.sendSessionDeath();
await session.flush();
await session.close();
if (sessionWrapper) {
sessionWrapper.stopKeepAlive();
}
// Stop HAPI MCP server
happyServer.stop();
session.updateMetadata((currentMetadata) => ({
...currentMetadata,
lifecycleState: 'archived',
lifecycleStateSince: Date.now(),
archivedBy: 'cli',
archiveReason: 'User terminated'
}));
logger.debug('[Codex] Session termination complete, exiting');
process.exit(0);
session.sendSessionDeath();
await session.flush();
await session.close();
logger.debug('[codex] Cleanup complete, exiting');
process.exit(code);
} catch (error) {
logger.debug('[Codex] Error during session termination:', error);
logger.debug('[codex] Error during cleanup:', error);
process.exit(1);
}
};
// Register abort handler
session.rpcHandlerManager.registerHandler('abort', handleAbort);
process.on('SIGTERM', () => cleanup(0));
process.on('SIGINT', () => cleanup(0));
registerKillSessionHandler(session.rpcHandlerManager, handleKillSession);
//
// Initialize Ink UI
//
const messageBuffer = new MessageBuffer();
const hasTTY = process.stdout.isTTY && process.stdin.isTTY;
let inkInstance: any = null;
if (hasTTY) {
console.clear();
inkInstance = render(React.createElement(CodexDisplay, {
messageBuffer,
logPath: process.env.DEBUG ? logger.getLogPath() : undefined,
onExit: async () => {
// Exit the agent
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");
}
//
// Start Context
//
const client = new CodexMcpClient();
// Helper: find Codex session transcript for a given sessionId
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');
// Recursively collect all files under the sessions directory
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; // newest first
});
return candidates[0] || null;
} catch {
return null;
}
}
const permissionHandler = new CodexPermissionHandler(session);
const reasoningProcessor = new ReasoningProcessor((message) => {
// Callback to send messages directly from the processor
session.sendCodexMessage(message);
});
const diffProcessor = new DiffProcessor((message) => {
// Callback to send messages directly from the processor
session.sendCodexMessage(message);
});
client.setPermissionHandler(permissionHandler);
client.setHandler((msg) => {
logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`);
// Add messages to the ink UI buffer based on message type
if (msg.type === 'agent_message') {
messageBuffer.addMessage(msg.message, 'assistant');
} else if (msg.type === 'agent_reasoning_delta') {
// Skip reasoning deltas in the UI to reduce noise
} 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) {
logger.debug('thinking started');
thinking = true;
session.keepAlive(thinking, 'remote');
}
}
if (msg.type === 'task_complete' || msg.type === 'turn_aborted') {
if (thinking) {
logger.debug('thinking completed');
thinking = false;
session.keepAlive(thinking, 'remote');
}
// Reset diff processor on task end or abort
diffProcessor.reset();
}
if (msg.type === 'agent_reasoning_section_break') {
// Reset reasoning processor for new section
reasoningProcessor.handleSectionBreak();
}
if (msg.type === 'agent_reasoning_delta') {
// Process reasoning delta - tool calls are sent automatically via callback
reasoningProcessor.processDelta(msg.delta);
}
if (msg.type === 'agent_reasoning') {
// Complete the reasoning section - tool results or reasoning messages sent via callback
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') {
let { 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') {
let { 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') {
// Handle the start of a patch operation
let { call_id, auto_approved, changes } = msg;
// Add UI feedback for patch operation
const changeCount = Object.keys(changes).length;
const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`;
messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool');
// Send tool call message
session.sendCodexMessage({
type: 'tool-call',
name: 'CodexPatch',
callId: call_id,
input: {
auto_approved,
changes
},
id: randomUUID()
});
}
if (msg.type === 'patch_apply_end') {
// Handle the end of a patch operation
let { call_id, stdout, stderr, success } = msg;
// Add UI feedback for completion
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');
}
// Send tool call result message
session.sendCodexMessage({
type: 'tool-call-result',
callId: call_id,
output: {
stdout,
stderr,
success
},
id: randomUUID()
});
}
if (msg.type === 'turn_diff') {
// Handle turn_diff messages and track unified_diff changes
if (msg.unified_diff) {
diffProcessor.processDiff(msg.unified_diff);
}
}
process.on('uncaughtException', (error) => {
logger.debug('[codex] Uncaught exception:', error);
exitCode = 1;
cleanup(1);
});
// Start HAPI MCP server (HTTP) and prepare STDIO bridge config for Codex
const happyServer = await startHappyServer(session);
const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]);
const mcpServers = {
hapi: {
command: bridgeCommand.command,
args: bridgeCommand.args
}
} as const;
let first = true;
process.on('unhandledRejection', (reason) => {
logger.debug('[codex] Unhandled rejection:', reason);
exitCode = 1;
cleanup(1);
});
registerKillSessionHandler(session.rpcHandlerManager, cleanup);
let loopError: unknown = null;
try {
logger.debug('[codex]: client.connect begin');
await client.connect();
logger.debug('[codex]: client.connect done');
let wasCreated = false;
let currentModeHash: string | null = null;
let pending: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = null;
// If we restart (e.g., mode change), use this to carry a resume file
let nextExperimentalResume: string | null = null;
while (!shouldExit) {
logActiveHandles('loop-top');
// Get next batch; respect mode boundaries like Claude
let message: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = pending;
pending = null;
if (!message) {
// Capture the current signal to distinguish idle-abort from queue close
const waitSignal = abortController.signal;
const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal);
if (!batch) {
// If wait was aborted (e.g., remote abort with no active inference), ignore and continue
if (waitSignal.aborted && !shouldExit) {
logger.debug('[codex]: Wait aborted while idle; ignoring and continuing');
continue;
}
logger.debug(`[codex]: batch=${!!batch}, shouldExit=${shouldExit}`);
break;
}
message = batch;
await loop({
path: workingDirectory,
startingMode,
messageQueue,
api,
session,
onModeChange: (newMode) => {
session.sendSessionEvent({ type: 'switch', mode: newMode });
session.updateAgentState((currentState) => ({
...currentState,
controlledByUser: newMode === 'local'
}));
},
onSessionReady: (instance) => {
sessionWrapper = instance;
}
// Defensive check for TS narrowing
if (!message) {
break;
}
// If a session exists and mode changed, restart on next iteration
if (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');
// Capture previous sessionId and try to find its transcript to resume
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 (e) {
logger.debug('[Codex] Error while searching resume file', e);
}
client.clearSession();
wasCreated = false;
currentModeHash = null;
pending = message;
// Reset processors/permissions like end-of-turn cleanup
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
thinking = false;
session.keepAlive(thinking, 'remote');
continue;
}
// Display user messages in the UI
messageBuffer.addMessage(message.message, 'user');
currentModeHash = message.hash;
try {
// Map permission mode to approval policy and sandbox for startSession
const approvalPolicy = (() => {
switch (message.mode.permissionMode) {
case 'default': return 'untrusted' as const;
case 'read-only': return 'never' as const;
case 'safe-yolo': return 'on-failure' as const;
case 'yolo': return 'on-failure' as const;
}
})();
const sandbox = (() => {
switch (message.mode.permissionMode) {
case 'default': return 'workspace-write' as const;
case 'read-only': return 'read-only' as const;
case 'safe-yolo': return 'workspace-write' as const;
case 'yolo': return 'danger-full-access' as const;
}
})();
if (!wasCreated) {
const startConfig: CodexSessionConfig = {
prompt: first ? message.message + '\n\n' + trimIdent(`Based on this message, call functions.hapi__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.`) : message.message,
sandbox,
'approval-policy': approvalPolicy,
config: { mcp_servers: mcpServers }
};
if (message.mode.model) {
startConfig.model = message.mode.model;
}
// Check for resume file from multiple sources
let resumeFile: string | null = null;
// Priority 1: Explicit resume file from mode change
if (nextExperimentalResume) {
resumeFile = nextExperimentalResume;
nextExperimentalResume = null; // consume once
logger.debug('[Codex] Using resume file from mode change:', resumeFile);
}
// Priority 2: Resume from stored abort session
else if (storedSessionIdForResume) {
const abortResumeFile = findCodexResumeFile(storedSessionIdForResume);
if (abortResumeFile) {
resumeFile = abortResumeFile;
logger.debug('[Codex] Using resume file from aborted session:', resumeFile);
messageBuffer.addMessage('Resuming from aborted session...', 'status');
}
storedSessionIdForResume = null; // consume once
}
// Apply resume file if found
if (resumeFile) {
(startConfig.config as any).experimental_resume = resumeFile;
}
await client.startSession(
startConfig,
{ signal: abortController.signal }
);
wasCreated = true;
first = false;
} else {
const response = await client.continueSession(
message.message,
{ signal: abortController.signal }
);
logger.debug('[Codex] continueSession response:', response);
}
} catch (error) {
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' });
// Session was already stored in handleAbort(), no need to store again
// Mark session as not created to force proper resume on next message
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' });
// For unexpected exits, try to store session for potential recovery
if (client.hasActiveSession()) {
storedSessionIdForResume = client.storeSessionForResume();
logger.debug('[Codex] Stored session after unexpected error:', storedSessionIdForResume);
}
}
} finally {
// Reset permission handler, reasoning processor, and diff processor
permissionHandler.reset();
reasoningProcessor.abort(); // Use abort to properly finish any in-progress tool calls
diffProcessor.reset();
thinking = false;
session.keepAlive(thinking, 'remote');
emitReadyIfIdle({
pending,
queueSize: () => messageQueue.size(),
shouldExit,
sendReady,
});
logActiveHandles('after-turn');
}
}
});
} catch (error) {
loopError = error;
exitCode = 1;
logger.debug('[codex] Loop error:', error);
} finally {
// Clean up resources when main loop exits
logger.debug('[codex]: Final cleanup start');
logActiveHandles('cleanup-start');
try {
logger.debug('[codex]: sendSessionDeath');
session.sendSessionDeath();
logger.debug('[codex]: flush begin');
await session.flush();
logger.debug('[codex]: flush done');
logger.debug('[codex]: session.close begin');
await session.close();
logger.debug('[codex]: session.close done');
} catch (e) {
logger.debug('[codex]: Error while closing session', e);
}
logger.debug('[codex]: client.disconnect begin');
await client.disconnect();
logger.debug('[codex]: client.disconnect done');
// Stop HAPI MCP server
logger.debug('[codex]: happyServer.stop');
happyServer.stop();
// Clean up ink UI
if (process.stdin.isTTY) {
logger.debug('[codex]: setRawMode(false)');
try { process.stdin.setRawMode(false); } catch { }
}
// Stop reading from stdin so the process can exit
if (hasTTY) {
logger.debug('[codex]: stdin.pause()');
try { process.stdin.pause(); } catch { }
}
// Clear periodic keep-alive to avoid keeping event loop alive
logger.debug('[codex]: clearInterval(keepAlive)');
clearInterval(keepAliveInterval);
if (inkInstance) {
logger.debug('[codex]: inkInstance.unmount()');
inkInstance.unmount();
}
messageBuffer.clear();
logActiveHandles('cleanup-end');
logger.debug('[codex]: Final cleanup completed');
await cleanup(loopError ? 1 : exitCode);
}
}
+78
View File
@@ -0,0 +1,78 @@
import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger';
import type { EnhancedMode } from './loop';
export class CodexSession {
readonly path: string;
readonly logPath: string;
readonly api: ApiClient;
readonly client: ApiSessionClient;
readonly queue: MessageQueue2<EnhancedMode>;
readonly _onModeChange: (mode: 'local' | 'remote') => void;
sessionId: string | null;
mode: 'local' | 'remote' = 'local';
thinking: boolean = false;
private keepAliveInterval: NodeJS.Timeout | null = null;
constructor(opts: {
api: ApiClient;
client: ApiSessionClient;
path: string;
logPath: string;
sessionId: string | null;
messageQueue: MessageQueue2<EnhancedMode>;
onModeChange: (mode: 'local' | 'remote') => void;
mode?: 'local' | 'remote';
}) {
this.path = opts.path;
this.api = opts.api;
this.client = opts.client;
this.logPath = opts.logPath;
this.sessionId = opts.sessionId;
this.queue = opts.messageQueue;
this._onModeChange = opts.onModeChange;
this.mode = opts.mode ?? 'local';
this.client.keepAlive(this.thinking, this.mode);
this.keepAliveInterval = setInterval(() => {
this.client.keepAlive(this.thinking, this.mode);
}, 2000);
}
onThinkingChange = (thinking: boolean) => {
this.thinking = thinking;
this.client.keepAlive(thinking, this.mode);
};
onModeChange = (mode: 'local' | 'remote') => {
this.mode = mode;
this.client.keepAlive(this.thinking, mode);
this._onModeChange(mode);
};
onSessionFound = (sessionId: string) => {
this.sessionId = sessionId;
this.client.updateMetadata((metadata) => ({
...metadata,
codexSessionId: sessionId
}));
logger.debug(`[CodexSession] Codex session ID ${sessionId} added to metadata`);
};
sendCodexMessage = (message: unknown): void => {
this.client.sendCodexMessage(message);
};
sendSessionEvent = (event: Parameters<ApiSessionClient['sendSessionEvent']>[0]): void => {
this.client.sendSessionEvent(event);
};
stopKeepAlive = (): void => {
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
this.keepAliveInterval = null;
}
};
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { convertCodexEvent } from './codexEventConverter';
describe('convertCodexEvent', () => {
it('extracts session_meta id', () => {
const result = convertCodexEvent({
type: 'session_meta',
payload: { id: 'session-123' }
});
expect(result).toEqual({ sessionId: 'session-123' });
});
it('converts agent_message events', () => {
const result = convertCodexEvent({
type: 'event_msg',
payload: { type: 'agent_message', message: 'hello' }
});
expect(result?.message).toMatchObject({
type: 'message',
message: 'hello'
});
});
it('converts reasoning events', () => {
const result = convertCodexEvent({
type: 'event_msg',
payload: { type: 'agent_reasoning', text: 'thinking' }
});
expect(result?.message).toMatchObject({
type: 'reasoning',
message: 'thinking'
});
});
it('converts reasoning delta events', () => {
const result = convertCodexEvent({
type: 'event_msg',
payload: { type: 'agent_reasoning_delta', delta: 'step' }
});
expect(result?.message).toEqual({
type: 'reasoning-delta',
delta: 'step'
});
});
it('converts function_call items', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'function_call',
name: 'ToolName',
call_id: 'call-1',
arguments: '{"foo":"bar"}'
}
});
expect(result?.message).toMatchObject({
type: 'tool-call',
name: 'ToolName',
callId: 'call-1',
input: { foo: 'bar' }
});
});
it('converts function_call_output items', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'function_call_output',
call_id: 'call-2',
output: { ok: true }
}
});
expect(result?.message).toMatchObject({
type: 'tool-call-result',
callId: 'call-2',
output: { ok: true }
});
});
});
+224
View File
@@ -0,0 +1,224 @@
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { logger } from '@/ui/logger';
const CodexSessionEventSchema = z.object({
timestamp: z.string().optional(),
type: z.string(),
payload: z.unknown().optional()
}).passthrough();
export type CodexSessionEvent = z.infer<typeof CodexSessionEventSchema>;
export type CodexMessage = {
type: 'message';
message: string;
id: string;
} | {
type: 'reasoning';
message: string;
id: string;
} | {
type: 'reasoning-delta';
delta: string;
} | {
type: 'token_count';
info: Record<string, unknown>;
id: string;
} | {
type: 'tool-call';
name: string;
callId: string;
input: unknown;
id: string;
} | {
type: 'tool-call-result';
callId: string;
output: unknown;
id: string;
};
export type CodexConversionResult = {
sessionId?: string;
message?: CodexMessage;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function parseArguments(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return JSON.parse(trimmed);
} catch (error) {
logger.debug('[codexEventConverter] Failed to parse function_call arguments as JSON:', error);
}
}
return value;
}
function extractCallId(payload: Record<string, unknown>): string | null {
const candidates = [
'call_id',
'callId',
'tool_call_id',
'toolCallId',
'id'
];
for (const key of candidates) {
const value = payload[key];
if (typeof value === 'string' && value.length > 0) {
return value;
}
}
return null;
}
export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | null {
const parsed = CodexSessionEventSchema.safeParse(rawEvent);
if (!parsed.success) {
return null;
}
const { type, payload } = parsed.data;
const payloadRecord = asRecord(payload);
if (type === 'session_meta') {
const sessionId = payloadRecord ? asString(payloadRecord.id) : null;
if (!sessionId) {
return null;
}
return { sessionId };
}
if (!payloadRecord) {
return null;
}
if (type === 'event_msg') {
const eventType = asString(payloadRecord.type);
if (!eventType) {
return null;
}
if (eventType === 'user_message') {
return null;
}
if (eventType === 'agent_message') {
const message = asString(payloadRecord.message);
if (!message) {
return null;
}
return {
message: {
type: 'message',
message,
id: randomUUID()
}
};
}
if (eventType === 'agent_reasoning') {
const message = asString(payloadRecord.text) ?? asString(payloadRecord.message);
if (!message) {
return null;
}
return {
message: {
type: 'reasoning',
message,
id: randomUUID()
}
};
}
if (eventType === 'agent_reasoning_delta') {
const delta = asString(payloadRecord.delta) ?? asString(payloadRecord.text) ?? asString(payloadRecord.message);
if (!delta) {
return null;
}
return {
message: {
type: 'reasoning-delta',
delta
}
};
}
if (eventType === 'token_count') {
const info = asRecord(payloadRecord.info);
if (!info) {
return null;
}
return {
message: {
type: 'token_count',
info,
id: randomUUID()
}
};
}
return null;
}
if (type === 'response_item') {
const itemType = asString(payloadRecord.type);
if (!itemType) {
return null;
}
if (itemType === 'function_call') {
const name = asString(payloadRecord.name);
const callId = extractCallId(payloadRecord);
if (!name || !callId) {
return null;
}
return {
message: {
type: 'tool-call',
name,
callId,
input: parseArguments(payloadRecord.arguments),
id: randomUUID()
}
};
}
if (itemType === 'function_call_output') {
const callId = extractCallId(payloadRecord);
if (!callId) {
return null;
}
return {
message: {
type: 'tool-call-result',
callId,
output: payloadRecord.output,
id: randomUUID()
}
};
}
return null;
}
return null;
}
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, writeFile, appendFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { existsSync } from 'node:fs';
import { createCodexSessionScanner } from './codexSessionScanner';
import type { CodexSessionEvent } from './codexEventConverter';
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe('codexSessionScanner', () => {
let testDir: string;
let sessionsDir: string;
let sessionFile: string;
let originalCodexHome: string | undefined;
let scanner: Awaited<ReturnType<typeof createCodexSessionScanner>> | null = null;
let events: CodexSessionEvent[] = [];
beforeEach(async () => {
testDir = join(tmpdir(), `codex-scanner-${Date.now()}`);
sessionsDir = join(testDir, 'sessions', '2025', '12', '22');
await mkdir(sessionsDir, { recursive: true });
originalCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = testDir;
events = [];
});
afterEach(async () => {
if (scanner) {
await scanner.cleanup();
scanner = null;
}
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = originalCodexHome;
}
if (existsSync(testDir)) {
await rm(testDir, { recursive: true, force: true });
}
});
it('emits only new events after startup', async () => {
const sessionId = 'session-123';
sessionFile = join(sessionsDir, `codex-${sessionId}.jsonl`);
const initialLines = [
JSON.stringify({ type: 'session_meta', payload: { id: sessionId } }),
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello' } })
];
await writeFile(sessionFile, initialLines.join('\n') + '\n');
scanner = await createCodexSessionScanner({
sessionId,
onEvent: (event) => events.push(event)
});
await wait(150);
expect(events).toHaveLength(0);
const newLine = JSON.stringify({
type: 'response_item',
payload: { type: 'function_call', name: 'Tool', call_id: 'call-1', arguments: '{}' }
});
await appendFile(sessionFile, newLine + '\n');
await wait(200);
expect(events).toHaveLength(1);
expect(events[0].type).toBe('response_item');
});
});
+219
View File
@@ -0,0 +1,219 @@
import { InvalidateSync } from '@/utils/sync';
import { startFileWatcher } from '@/modules/watcher/startFileWatcher';
import { logger } from '@/ui/logger';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { readFile, readdir, stat } from 'node:fs/promises';
import type { CodexSessionEvent } from './codexEventConverter';
interface CodexSessionScannerOptions {
sessionId: string | null;
onEvent: (event: CodexSessionEvent) => void;
onSessionFound?: (sessionId: string) => void;
}
interface CodexSessionScanner {
cleanup: () => Promise<void>;
onNewSession: (sessionId: string) => void;
}
export async function createCodexSessionScanner(opts: CodexSessionScannerOptions): Promise<CodexSessionScanner> {
const codexHomeDir = process.env.CODEX_HOME || join(homedir(), '.codex');
const sessionsRoot = join(codexHomeDir, 'sessions');
const processedLineCounts = new Map<string, number>();
const watchers = new Map<string, () => void>();
const sessionIdByFile = new Map<string, string>();
let activeSessionId: string | null = opts.sessionId;
let reportedSessionId: string | null = opts.sessionId;
let isClosing = false;
const reportSessionId = (sessionId: string) => {
if (reportedSessionId === sessionId) {
return;
}
reportedSessionId = sessionId;
opts.onSessionFound?.(sessionId);
};
const setActiveSessionId = (sessionId: string) => {
activeSessionId = sessionId;
reportSessionId(sessionId);
};
async function listSessionFiles(dir: string): Promise<string[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...await listSessionFiles(full));
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
results.push(full);
}
}
return results;
} catch (error) {
return [];
}
}
async function readSessionFile(filePath: string, startLine: number): Promise<{ events: CodexSessionEvent[]; totalLines: number }> {
let content: string;
try {
content = await readFile(filePath, 'utf-8');
} catch (error) {
return { events: [], totalLines: startLine };
}
const events: CodexSessionEvent[] = [];
const lines = content.split('\n');
const hasTrailingEmpty = lines.length > 0 && lines[lines.length - 1] === '';
const totalLines = hasTrailingEmpty ? lines.length - 1 : lines.length;
let effectiveStartLine = startLine;
if (effectiveStartLine > totalLines) {
effectiveStartLine = 0;
}
const hasSessionId = sessionIdByFile.has(filePath);
const parseFrom = hasSessionId ? effectiveStartLine : 0;
for (let index = parseFrom; index < lines.length; index += 1) {
const trimmed = lines[index].trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed);
if (parsed?.type === 'session_meta') {
const payload = asRecord(parsed.payload);
const sessionId = payload ? asString(payload.id) : null;
if (sessionId) {
sessionIdByFile.set(filePath, sessionId);
}
}
if (index >= effectiveStartLine) {
events.push(parsed);
}
} catch (error) {
logger.debug(`[CODEX_SESSION_SCANNER] Failed to parse line: ${error}`);
}
}
return { events, totalLines };
}
async function initializeProcessedMessages(): Promise<void> {
const files = await listSessionFiles(sessionsRoot);
for (const filePath of files) {
const { totalLines } = await readSessionFile(filePath, 0);
processedLineCounts.set(filePath, totalLines);
if (!isClosing && !watchers.has(filePath)) {
watchers.set(filePath, startFileWatcher(filePath, () => sync.invalidate()));
}
}
}
const sync = new InvalidateSync(async () => {
if (isClosing) {
return;
}
const files = await listSessionFiles(sessionsRoot);
const sortedFiles = await sortFilesByMtime(files);
for (const filePath of sortedFiles) {
if (isClosing) {
return;
}
if (!watchers.has(filePath)) {
watchers.set(filePath, startFileWatcher(filePath, () => sync.invalidate()));
}
const fileSessionId = sessionIdByFile.get(filePath);
if (activeSessionId && fileSessionId && fileSessionId !== activeSessionId) {
continue;
}
if (activeSessionId && !fileSessionId && !filePath.endsWith(`-${activeSessionId}.jsonl`)) {
continue;
}
const lastProcessedLine = processedLineCounts.get(filePath) ?? 0;
const { events, totalLines } = await readSessionFile(filePath, lastProcessedLine);
processedLineCounts.set(filePath, totalLines);
let emittedForFile = 0;
for (const event of events) {
const payload = asRecord(event.payload);
const payloadSessionId = payload ? asString(payload.id) : null;
const eventSessionId = payloadSessionId ?? fileSessionId ?? null;
if (!activeSessionId && eventSessionId) {
setActiveSessionId(eventSessionId);
}
if (activeSessionId && eventSessionId && eventSessionId !== activeSessionId) {
continue;
}
opts.onEvent(event);
emittedForFile += 1;
}
if (emittedForFile > 0) {
logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`);
}
}
});
await initializeProcessedMessages();
await sync.invalidateAndAwait();
const intervalId = setInterval(() => sync.invalidate(), 2000);
return {
cleanup: async () => {
isClosing = true;
clearInterval(intervalId);
sync.stop();
for (const stop of watchers.values()) {
stop();
}
watchers.clear();
},
onNewSession: (sessionId: string) => {
if (activeSessionId === sessionId) {
return;
}
logger.debug(`[CODEX_SESSION_SCANNER] Switching to new session: ${sessionId}`);
setActiveSessionId(sessionId);
sync.invalidate();
}
};
}
async function sortFilesByMtime(files: string[]): Promise<string[]> {
const entries = await Promise.all(files.map(async (file) => {
try {
const stats = await stat(file);
return { file, mtimeMs: stats.mtimeMs };
} catch {
return { file, mtimeMs: 0 };
}
}));
return entries
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map((entry) => entry.file);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
+27
View File
@@ -0,0 +1,27 @@
export type ReadyEventOptions = {
pending: unknown;
queueSize: () => number;
shouldExit: boolean;
sendReady: () => void;
notify?: () => void;
};
/**
* Notify connected clients when Codex finishes processing and the queue is idle.
* Returns true when a ready event was emitted.
*/
export function emitReadyIfIdle({ pending, queueSize, shouldExit, sendReady, notify }: ReadyEventOptions): boolean {
if (shouldExit) {
return false;
}
if (pending) {
return false;
}
if (queueSize() > 0) {
return false;
}
sendReady();
notify?.();
return true;
}