fix(codex): defer session persistence until user activity

This commit is contained in:
weishu
2026-07-12 11:00:08 +08:00
parent 65e1708c78
commit 5a377e38b9
35 changed files with 2118 additions and 131 deletions
@@ -41,6 +41,16 @@ describe('parseCodexCliOverrides', () => {
expect(parseCodexCliOverrides(['-a', 'untrusted', '-a', 'on-failure'])).toEqual({
approvalPolicy: 'on-failure'
});
expect(parseCodexCliOverrides(['-C', 'first', '--cd=second'])).toEqual({
cwd: 'second'
});
});
it('parses cwd overrides before the argument terminator', () => {
expect(parseCodexCliOverrides(['--cd', '../project'])).toEqual({ cwd: '../project' });
expect(parseCodexCliOverrides(['-C=/tmp/project'])).toEqual({ cwd: '/tmp/project' });
expect(parseCodexCliOverrides(['--', '--cd', '/tmp/ignored'])).toEqual({});
});
it('ignores invalid values and stops at terminator', () => {
+18
View File
@@ -1,6 +1,7 @@
export type CodexCliOverrides = {
sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never';
cwd?: string;
};
const SANDBOX_VALUES = new Set<CodexCliOverrides['sandbox']>([
@@ -46,6 +47,23 @@ export function parseCodexCliOverrides(args?: string[]): CodexCliOverrides {
continue;
}
if (arg === '-C' || arg === '--cd') {
const value = args[i + 1];
if (value && value !== '--') {
overrides.cwd = value;
i += 1;
}
continue;
}
if (arg.startsWith('--cd=') || arg.startsWith('-C=')) {
const value = arg.slice(arg.indexOf('=') + 1);
if (value) {
overrides.cwd = value;
}
continue;
}
if (arg === '-s' || arg === '--sandbox') {
const value = args[i + 1];
if (SANDBOX_VALUES.has(value as CodexCliOverrides['sandbox'])) {
@@ -43,10 +43,24 @@ describe('convertCodexEvent', () => {
});
expect(result).toEqual({
userMessage: 'hello from response_item user'
userMessage: 'hello from response_item user',
userActivity: true
});
});
it('marks image-only response_item messages as user activity', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
}
});
expect(result).toEqual({ userActivity: true });
});
it('converts response_item assistant messages', () => {
const result = convertCodexEvent({
type: 'response_item',
+10 -8
View File
@@ -42,6 +42,7 @@ export type CodexConversionResult = {
sessionId?: string;
message?: CodexMessage;
userMessage?: string;
userActivity?: true;
};
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -146,11 +147,9 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
const message = asString(payloadRecord.message)
?? asString(payloadRecord.text)
?? asString(payloadRecord.content);
if (!message) {
return null;
}
return {
userMessage: message
userActivity: true,
...(message ? { userMessage: message } : {})
};
}
@@ -221,13 +220,16 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
if (itemType === 'message') {
const role = asString(payloadRecord.role);
const text = extractCodexText(payloadRecord.content);
if (!text) {
return null;
}
if (role === 'user') {
return { userMessage: text };
return {
userActivity: true,
...(text ? { userMessage: text } : {})
};
}
if (role === 'assistant') {
if (!text) {
return null;
}
return {
message: {
type: 'message',
@@ -163,4 +163,30 @@ describe('codexSessionScanner', () => {
expect(events).toHaveLength(1);
expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'after-truncate' });
});
it('retries an unterminated final record after it is completed', async () => {
await writeFile(
transcriptPath,
JSON.stringify({ type: 'session_meta', payload: { id: 'session-partial' } }) + '\n'
);
scanner = await createCodexSessionScanner({
transcriptPath,
onEvent: (event) => events.push(event)
});
const event = JSON.stringify({
type: 'event_msg',
payload: { type: 'agent_message', message: 'completed later' }
});
const splitAt = Math.floor(event.length / 2);
await appendFile(transcriptPath, event.slice(0, splitAt));
await wait(300);
expect(events).toEqual([]);
await appendFile(transcriptPath, event.slice(splitAt));
await wait(700);
expect(events).toHaveLength(1);
expect(events[0]?.payload).toEqual({ type: 'agent_message', message: 'completed later' });
});
});
+5 -1
View File
@@ -122,6 +122,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
const lines = content.split('\n');
const hasTrailingEmpty = lines.length > 0 && lines[lines.length - 1] === '';
const totalLines = hasTrailingEmpty ? lines.length - 1 : lines.length;
let nextCursor = totalLines;
const currentSize = Buffer.byteLength(content);
const previousSize = this.fileSizeByPath.get(filePath);
let effectiveStartLine = startLine;
@@ -145,6 +146,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
parsed = JSON.parse(line);
} catch (error) {
logger.debug(`[codex-session-scanner] Failed to parse transcript line ${filePath}:${lineIndex + 1}: ${error}`);
if (!hasTrailingEmpty && lineIndex === totalLines - 1) {
nextCursor = lineIndex;
}
continue;
}
@@ -169,7 +173,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
return {
events,
nextCursor: totalLines
nextCursor
};
}
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './codexTranscriptLocator';
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe('codexTranscriptLocator', () => {
let codexHome: string;
let sessionDirectory: string;
let locator: CodexTranscriptLocator | null = null;
const originalCodexHome = process.env.CODEX_HOME;
beforeEach(async () => {
codexHome = join(tmpdir(), `codex-transcript-locator-${Date.now()}-${Math.random()}`);
const now = new Date();
sessionDirectory = join(
codexHome,
'sessions',
String(now.getUTCFullYear()),
String(now.getUTCMonth() + 1).padStart(2, '0'),
String(now.getUTCDate()).padStart(2, '0')
);
await mkdir(sessionDirectory, { recursive: true });
process.env.CODEX_HOME = codexHome;
});
afterEach(async () => {
await locator?.cleanup();
locator = null;
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = originalCodexHome;
}
await rm(codexHome, { recursive: true, force: true });
});
it('does not attach for session metadata alone', async () => {
const located: string[] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
onLocated: (result) => located.push(result.transcriptPath)
});
await locator.ready;
const transcriptPath = await createTranscript('thread-meta-only', '/tmp/project');
await wait(150);
expect(located).toEqual([]);
expect(transcriptPath).toContain('thread-meta-only');
});
it('attaches after fresh real user activity in the matching cwd', async () => {
const located: string[] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
settlementMs: 25,
onLocated: (result) => located.push(result.transcriptPath)
});
await locator.ready;
const transcriptPath = await createTranscript('thread-user', '/tmp/project');
await appendFile(transcriptPath, `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'hello' }
})}\n`);
await wait(150);
expect(located).toEqual([transcriptPath]);
});
it('attaches after image-only user activity', async () => {
const located: string[] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
settlementMs: 0,
onLocated: (result) => located.push(result.transcriptPath)
});
await locator.ready;
const transcriptPath = await createTranscript('thread-image', '/tmp/project');
await appendFile(transcriptPath, `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }]
}
})}\n`);
await wait(100);
expect(located).toEqual([transcriptPath]);
});
it('refuses fallback when fresh activity is ambiguous', async () => {
const located: string[] = [];
const ambiguous: string[][] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
settlementMs: 50,
onLocated: (result) => located.push(result.transcriptPath),
onAmbiguous: (paths) => ambiguous.push(paths)
});
await locator.ready;
const first = await createTranscript('thread-a', '/tmp/project');
const second = await createTranscript('thread-b', '/tmp/project');
const userEvent = `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'hello' }
})}\n`;
await Promise.all([appendFile(first, userEvent), appendFile(second, userEvent)]);
await wait(150);
expect(located).toEqual([]);
expect(ambiguous).toHaveLength(1);
expect(new Set(ambiguous[0])).toEqual(new Set([first, second]));
});
it('rejects candidates whose activity arrives in adjacent polling cycles', async () => {
const located: string[] = [];
const ambiguous: string[][] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
settlementMs: 150,
onLocated: (result) => located.push(result.transcriptPath),
onAmbiguous: (paths) => ambiguous.push(paths)
});
await locator.ready;
const first = await createTranscript('thread-staggered-a', '/tmp/project');
const second = await createTranscript('thread-staggered-b', '/tmp/project');
const userEvent = (message: string) => JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message }
});
await appendFile(first, `${userEvent('first')}\n`);
await wait(60);
await appendFile(second, `${userEvent('second')}\n`);
await wait(150);
expect(located).toEqual([]);
expect(ambiguous).toHaveLength(1);
expect(new Set(ambiguous[0])).toEqual(new Set([first, second]));
});
it('retries an unterminated final JSON record after it is completed', async () => {
const located: string[] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
settlementMs: 0,
onLocated: (result) => located.push(result.transcriptPath)
});
await locator.ready;
const transcriptPath = await createTranscript('thread-partial', '/tmp/project');
const userEvent = JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'completed later' }
});
const splitAt = Math.floor(userEvent.length / 2);
await appendFile(transcriptPath, userEvent.slice(0, splitAt));
await wait(75);
expect(located).toEqual([]);
await appendFile(transcriptPath, userEvent.slice(splitAt));
await wait(100);
expect(located).toEqual([transcriptPath]);
});
it('ignores pre-existing fresh transcripts even when they receive new activity', async () => {
const transcriptPath = await createTranscript('thread-existing', '/tmp/project');
const located: string[] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
settlementMs: 0,
onLocated: (result) => located.push(result.transcriptPath)
});
await locator.ready;
await appendFile(transcriptPath, `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'other terminal' }
})}\n`);
await wait(100);
expect(located).toEqual([]);
});
it('polls only the exact resume transcript once it is found', async () => {
const unrelated = await createTranscript('thread-unrelated', '/tmp/project');
const target = await createTranscript('thread-resume', '/tmp/original-project');
await appendFile(unrelated, `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'unrelated activity' }
})}\n`);
const located: string[] = [];
const ambiguous: string[][] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/current-project',
startupTimestampMs: Date.now(),
resumeSessionId: 'thread-resume',
intervalMs: 25,
onLocated: (result) => located.push(result.transcriptPath),
onAmbiguous: (paths) => ambiguous.push(paths)
});
await locator.ready;
await appendFile(target, `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'resume activity' }
})}\n`);
await wait(100);
expect(located).toEqual([target]);
expect(ambiguous).toEqual([]);
});
async function createTranscript(sessionId: string, cwd: string): Promise<string> {
const transcriptPath = join(sessionDirectory, `rollout-${sessionId}.jsonl`);
await writeFile(transcriptPath, `${JSON.stringify({
type: 'session_meta',
payload: { id: sessionId, cwd }
})}\n`);
return transcriptPath;
}
});
@@ -0,0 +1,380 @@
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { open, readdir, stat } from 'node:fs/promises';
import { logger } from '@/ui/logger';
import { convertCodexEvent, type CodexSessionEvent } from './codexEventConverter';
export type LocatedCodexTranscript = {
sessionId: string;
transcriptPath: string;
};
export type CodexTranscriptLocator = {
ready: Promise<void>;
cleanup: () => Promise<void>;
};
type TranscriptState = {
offset: number;
size: number;
mtimeMs: number;
ino: number;
sessionId: string | null;
cwd: string | null;
};
type CodexTranscriptLocatorOptions = {
cwd: string;
startupTimestampMs: number;
resumeSessionId?: string | null;
intervalMs?: number;
settlementMs?: number;
onLocated: (located: LocatedCodexTranscript) => void;
onAmbiguous?: (paths: string[]) => void;
};
const DEFAULT_LOCATOR_INTERVAL_MS = 500;
export function createCodexTranscriptLocator(options: CodexTranscriptLocatorOptions): CodexTranscriptLocator {
const locator = new CodexTranscriptLocatorImpl(options);
const ready = locator.start().catch((error) => {
logger.debug('[codex-transcript-locator] Failed to initialize transcript fallback', error);
});
return {
ready,
cleanup: async () => {
await locator.cleanup();
await ready;
}
};
}
class CodexTranscriptLocatorImpl {
private readonly sessionsRoot: string;
private readonly targetCwd: string;
private readonly startupTimestampMs: number;
private readonly resumeSessionId: string | null;
private readonly intervalMs: number;
private readonly settlementMs: number;
private readonly onLocated: CodexTranscriptLocatorOptions['onLocated'];
private readonly onAmbiguous?: CodexTranscriptLocatorOptions['onAmbiguous'];
private readonly states = new Map<string, TranscriptState>();
private readonly initialFreshPaths = new Set<string>();
private readonly pendingCandidates = new Map<string, LocatedCodexTranscript>();
private resumeTranscriptPaths: string[] | null = null;
private firstCandidateTimestampMs: number | null = null;
private interval: ReturnType<typeof setInterval> | null = null;
private scanPromise: Promise<void> | null = null;
private stopped = false;
constructor(options: CodexTranscriptLocatorOptions) {
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
this.sessionsRoot = join(codexHome, 'sessions');
this.targetCwd = normalizePath(options.cwd);
this.startupTimestampMs = options.startupTimestampMs;
this.resumeSessionId = options.resumeSessionId ?? null;
this.intervalMs = options.intervalMs ?? DEFAULT_LOCATOR_INTERVAL_MS;
this.settlementMs = options.settlementMs ?? this.intervalMs;
this.onLocated = options.onLocated;
this.onAmbiguous = options.onAmbiguous;
}
async start(): Promise<void> {
if (!this.resumeSessionId) {
const existingPaths = await this.listNearbyTranscriptFiles();
for (const transcriptPath of existingPaths) {
this.initialFreshPaths.add(transcriptPath);
}
}
if (this.stopped) return;
void this.scan();
this.interval = setInterval(() => void this.scan(), this.intervalMs);
this.interval.unref?.();
}
async cleanup(): Promise<void> {
this.stopped = true;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
await this.scanPromise?.catch(() => {});
}
private async scan(): Promise<void> {
if (this.stopped || this.scanPromise) {
return this.scanPromise ?? Promise.resolve();
}
this.scanPromise = this.runScan();
try {
await this.scanPromise;
} finally {
this.scanPromise = null;
}
}
private async runScan(): Promise<void> {
const files = await this.listCandidateFiles();
for (const transcriptPath of files) {
if (this.stopped) return;
const candidate = await this.scanFile(transcriptPath);
if (candidate) {
this.pendingCandidates.set(candidate.transcriptPath, candidate);
}
}
if (this.stopped || this.pendingCandidates.size === 0) {
return;
}
if (this.pendingCandidates.size > 1) {
const paths = [...this.pendingCandidates.keys()];
logger.warn('[codex-transcript-locator] Ambiguous Codex transcript activity; refusing fallback attachment', paths);
this.stopPolling();
this.onAmbiguous?.(paths);
return;
}
const [located] = this.pendingCandidates.values();
if (!located) return;
if (!this.resumeSessionId) {
if (this.firstCandidateTimestampMs === null) {
this.firstCandidateTimestampMs = Date.now();
}
if (Date.now() - this.firstCandidateTimestampMs < this.settlementMs) {
return;
}
}
logger.debug(`[codex-transcript-locator] Located ${located.sessionId} at ${located.transcriptPath}`);
this.stopPolling();
this.onLocated(located);
}
private async scanFile(transcriptPath: string): Promise<LocatedCodexTranscript | null> {
let fileStats: Awaited<ReturnType<typeof stat>>;
try {
fileStats = await stat(transcriptPath);
} catch {
return null;
}
if (!fileStats.isFile()) return null;
const previous = this.states.get(transcriptPath);
let state: TranscriptState = previous ?? {
offset: 0,
size: 0,
mtimeMs: 0,
ino: fileStats.ino,
sessionId: null,
cwd: null
};
const replaced = previous && previous.ino !== fileStats.ino;
const truncated = previous && fileStats.size < previous.offset;
const rewrittenAtSameSize = previous
&& fileStats.size === previous.size
&& fileStats.mtimeMs !== previous.mtimeMs
&& previous.offset === previous.size;
if (replaced || truncated || rewrittenAtSameSize) {
state = {
offset: 0,
size: 0,
mtimeMs: 0,
ino: fileStats.ino,
sessionId: null,
cwd: null
};
} else if (previous
&& fileStats.size === previous.size
&& fileStats.mtimeMs === previous.mtimeMs) {
return null;
}
if (fileStats.size <= state.offset) {
state.size = fileStats.size;
state.mtimeMs = fileStats.mtimeMs;
state.ino = fileStats.ino;
this.states.set(transcriptPath, state);
return null;
}
let content: Buffer;
try {
content = await readBytes(transcriptPath, state.offset, fileStats.size - state.offset);
} catch {
return null;
}
const startOffset = state.offset;
const text = content.toString('utf8');
const lines = text.split('\n');
let consumedBytes = 0;
let sawFreshUserActivity = false;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? '';
const terminated = index < lines.length - 1;
const recordBytes = Buffer.byteLength(line) + (terminated ? 1 : 0);
if (!line.trim()) {
if (terminated) consumedBytes += recordBytes;
continue;
}
let event: CodexSessionEvent;
try {
event = JSON.parse(line) as CodexSessionEvent;
} catch {
if (terminated) consumedBytes += recordBytes;
continue;
}
if (event.type === 'session_meta') {
const metadata = asRecord(event.payload);
state.sessionId = asString(metadata?.id) ?? state.sessionId;
const eventCwd = asString(metadata?.cwd);
state.cwd = eventCwd ? normalizePath(eventCwd) : state.cwd;
}
if (convertCodexEvent(event)?.userActivity) {
const eventTimestamp = parseTimestamp(event.timestamp);
if (eventTimestamp !== null && eventTimestamp >= this.startupTimestampMs) {
sawFreshUserActivity = true;
}
}
consumedBytes += recordBytes;
}
state.offset = startOffset + consumedBytes;
state.size = startOffset + content.length;
state.mtimeMs = fileStats.mtimeMs;
state.ino = fileStats.ino;
this.states.set(transcriptPath, state);
if (!sawFreshUserActivity || !state.sessionId) {
return null;
}
if (this.resumeSessionId) {
if (state.sessionId !== this.resumeSessionId) {
return null;
}
} else if (state.cwd !== this.targetCwd) {
return null;
}
return { sessionId: state.sessionId, transcriptPath };
}
private async listCandidateFiles(): Promise<string[]> {
if (this.resumeSessionId) {
if (this.resumeTranscriptPaths) {
return this.resumeTranscriptPaths;
}
const suffix = `-${this.resumeSessionId}.jsonl`;
const matches = await listJsonlFiles(this.sessionsRoot, (name) => name.endsWith(suffix));
if (matches.length > 0) {
this.resumeTranscriptPaths = matches;
}
return matches;
}
const files = await this.listNearbyTranscriptFiles();
return files.filter((transcriptPath) => !this.initialFreshPaths.has(transcriptPath));
}
private async listNearbyTranscriptFiles(): Promise<string[]> {
const roots = getNearbyDateRoots(this.sessionsRoot, this.startupTimestampMs);
const groups = await Promise.all(roots.map((root) => listJsonlFiles(root)));
return groups.flat();
}
private stopPolling(): void {
this.stopped = true;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
async function readBytes(filePath: string, offset: number, length: number): Promise<Buffer> {
const handle = await open(filePath, 'r');
try {
const buffer = Buffer.alloc(length);
let totalBytesRead = 0;
while (totalBytesRead < length) {
const { bytesRead } = await handle.read(
buffer,
totalBytesRead,
length - totalBytesRead,
offset + totalBytesRead
);
if (bytesRead === 0) break;
totalBytesRead += bytesRead;
}
return buffer.subarray(0, totalBytesRead);
} finally {
await handle.close();
}
}
async function listJsonlFiles(
directory: string,
matchesName: (name: string) => boolean = () => true
): Promise<string[]> {
try {
const entries = await readdir(directory, { withFileTypes: true });
const groups = await Promise.all(entries.map(async (entry) => {
const fullPath = join(directory, entry.name);
if (entry.isDirectory()) {
return await listJsonlFiles(fullPath, matchesName);
}
return entry.isFile() && entry.name.endsWith('.jsonl') && matchesName(entry.name)
? [fullPath]
: [];
}));
return groups.flat();
} catch {
return [];
}
}
function getNearbyDateRoots(sessionsRoot: string, timestampMs: number): string[] {
const roots: string[] = [];
for (const offsetDays of [-1, 0, 1]) {
const date = new Date(timestampMs + offsetDays * 24 * 60 * 60 * 1000);
roots.push(join(
sessionsRoot,
String(date.getUTCFullYear()),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
));
}
return roots;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function parseTimestamp(value: unknown): number | null {
if (typeof value !== 'string') return null;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}
function normalizePath(value: string): string {
const normalized = resolve(value);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
+3 -1
View File
@@ -18,6 +18,7 @@ vi.mock('cross-spawn', () => ({
import {
assertCodexLocalSupported,
CODEX_VERSION_TIMEOUT_MS,
isCodexVersionAtLeast,
MIN_CODEX_HOOKS_VERSION,
parseCodexVersion
@@ -74,7 +75,8 @@ describe('codexVersion', () => {
'node',
[codexScriptPath, '--version'],
expect.objectContaining({
encoding: 'utf8'
encoding: 'utf8',
timeout: CODEX_VERSION_TIMEOUT_MS
})
)
})
+2
View File
@@ -3,6 +3,7 @@ import { withBunRuntimeEnv } from '@/utils/bunRuntime'
import { resolveCodexCommand } from './codexExecutable'
export const MIN_CODEX_HOOKS_VERSION = '0.124.0'
export const CODEX_VERSION_TIMEOUT_MS = 3_000
const SEMVER_PATTERN = /\b(\d+)\.(\d+)\.(\d+)\b/
@@ -59,6 +60,7 @@ export function assertCodexLocalSupported(): void {
const result = spawn.sync(codexCommand.command, [...codexCommand.args, '--version'], {
encoding: 'utf8',
env: withBunRuntimeEnv(),
timeout: CODEX_VERSION_TIMEOUT_MS,
windowsHide: process.platform === 'win32'
})