fix: stop codex transcript replay after hooks migration (#2365)

This commit is contained in:
Alex Newman
2026-05-21 01:48:59 -07:00
committed by GitHub
parent e53d1530ff
commit b0e896e286
9 changed files with 398 additions and 37 deletions
+82 -9
View File
@@ -1,7 +1,7 @@
import path from 'path';
import { homedir } from 'os';
import { execFileSync, spawnSync } from 'child_process';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { logger } from '../../utils/logger.js';
import { paths } from '../../shared/paths.js';
@@ -9,7 +9,10 @@ import { paths } from '../../shared/paths.js';
const CODEX_DIR = path.join(homedir(), '.codex');
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
const CODEX_TRANSCRIPT_WATCH_CONFIG_PATH = paths.transcriptsConfig();
const CODEX_CONFIG_PATH = path.join(CODEX_DIR, 'config.toml');
const MARKETPLACE_NAME = 'claude-mem-local';
const CODEX_PLUGIN_ID = `claude-mem@${MARKETPLACE_NAME}`;
const LEGACY_CODEX_PLUGIN_IDS = ['claude-mem@thedotmack'];
const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0';
const REQUIRED_MARKETPLACE_FILES = [
path.join('.agents', 'plugins', 'marketplace.json'),
@@ -131,6 +134,74 @@ function registerCodexMarketplace(marketplaceRoot: string): void {
runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]);
}
export function setTomlBooleanInTable(content: string, header: string, key: string, enabled: boolean): string {
const booleanLine = `${key} = ${enabled ? 'true' : 'false'}`;
const lines = content.split('\n');
const headerIndex = lines.findIndex((line) => line.trim() === header);
if (headerIndex === -1) {
const trimmed = content.trimEnd();
return `${trimmed}${trimmed ? '\n\n' : ''}${header}\n${booleanLine}\n`;
}
let sectionEnd = headerIndex + 1;
while (sectionEnd < lines.length && !/^\s*\[/.test(lines[sectionEnd])) {
sectionEnd += 1;
}
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const keyPattern = new RegExp(`^\\s*${escapedKey}\\s*=`);
const keyIndex = lines.findIndex(
(line, index) => index > headerIndex && index < sectionEnd && keyPattern.test(line),
);
if (keyIndex === -1) {
lines.splice(headerIndex + 1, 0, booleanLine);
} else {
lines[keyIndex] = booleanLine;
}
return lines.join('\n');
}
export function setTomlPluginEnabled(content: string, pluginId: string, enabled: boolean): string {
const escapedPluginId = pluginId.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return setTomlBooleanInTable(content, `[plugins."${escapedPluginId}"]`, 'enabled', enabled);
}
export function setTomlFeatureEnabled(content: string, featureName: string, enabled: boolean): string {
return setTomlBooleanInTable(content, '[features]', featureName, enabled);
}
function writeCodexPluginConfig(enabled: boolean): boolean {
if (!enabled && !existsSync(CODEX_CONFIG_PATH)) return false;
mkdirSync(CODEX_DIR, { recursive: true });
const current = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, 'utf-8') : '';
let next = current;
if (enabled) {
next = setTomlFeatureEnabled(next, 'hooks', true);
}
for (const legacyPluginId of LEGACY_CODEX_PLUGIN_IDS) {
next = setTomlPluginEnabled(next, legacyPluginId, false);
}
next = setTomlPluginEnabled(next, CODEX_PLUGIN_ID, enabled);
if (next === current) return false;
writeFileSync(CODEX_CONFIG_PATH, next);
return true;
}
function enableCodexPluginConfig(): void {
const changed = writeCodexPluginConfig(true);
console.log(` Enabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already enabled)'}`);
}
function disableCodexPluginConfig(): void {
const changed = writeCodexPluginConfig(false);
console.log(` Disabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already disabled)'}`);
}
function parseSemver(value: string): [number, number, number] | null {
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
@@ -284,16 +355,12 @@ export async function installCodexCli(marketplaceRootOverride?: string): Promise
console.log(` Registering Codex plugin marketplace: ${marketplaceRoot}`);
registerCodexMarketplace(marketplaceRoot);
enableCodexPluginConfig();
runCodexBestEffort(
['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME],
'Refreshed Codex marketplace and installed plugin cache.',
'Could not refresh Codex marketplace cache; reinstall or upgrade claude-mem from /plugins if Codex still uses old MCP config',
);
runCodexBestEffort(
['features', 'enable', 'plugin_hooks'],
'Enabled Codex plugin_hooks so claude-mem hooks can run.',
'Could not enable Codex plugin_hooks; run `codex features enable plugin_hooks` if context hooks do not appear',
);
if (!cleanupLegacyCodexAgentsMdContext()) {
console.warn(` Native Codex hooks registered, but failed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`);
}
@@ -309,9 +376,7 @@ Plugin source: ${marketplaceRoot}
Next steps:
1. Open Codex CLI in your project
2. Run /plugins
3. Install claude-mem from the claude-mem (local) marketplace
4. Restart Codex CLI after install so MCP tools and plugin hooks reload
2. Restart any running Codex sessions so native hooks are loaded
For a fresh setup, the supported entry point is:
npx claude-mem@latest install
@@ -329,6 +394,14 @@ export function uninstallCodexCli(): number {
let failed = false;
try {
disableCodexPluginConfig();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`\nCodex plugin config update failed: ${message}`);
failed = true;
}
try {
if (commandExists('codex')) {
runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
+32 -13
View File
@@ -7,10 +7,10 @@ import type { TranscriptSchema, TranscriptWatchConfig } from './types.js';
export const DEFAULT_CONFIG_PATH = paths.transcriptsConfig();
export const DEFAULT_STATE_PATH = paths.transcriptsState();
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
export const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
name: 'codex',
version: '0.3',
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
description: 'Legacy schema for Codex session JSONL files. Codex native hooks are preferred.',
events: [
{
name: 'session-meta',
@@ -109,20 +109,39 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
export const SAMPLE_CONFIG: TranscriptWatchConfig = {
version: 1,
schemas: {
codex: CODEX_SAMPLE_SCHEMA
},
watches: [
{
name: 'codex',
path: '~/.codex/sessions/**/*.jsonl',
schema: 'codex',
startAtEnd: true
}
],
schemas: {},
watches: [],
stateFile: DEFAULT_STATE_PATH
};
export function isNativeHookBackedCodexWatch(watch: { name?: string; path?: string; schema?: string | TranscriptSchema }): boolean {
const schemaName = typeof watch.schema === 'string' ? watch.schema : watch.schema?.name;
const nameOrSchemaIsCodex = watch.name === 'codex' || schemaName === 'codex';
if (!nameOrSchemaIsCodex || !watch.path) return false;
const normalizedPath = expandHomePath(watch.path).replace(/\\/g, '/');
const codexSessionsRoot = join(homedir(), '.codex', 'sessions').replace(/\\/g, '/');
return normalizedPath === `${codexSessionsRoot}/**/*.jsonl`;
}
export function filterNativeHookBackedCodexWatches(
config: TranscriptWatchConfig,
allowCodexTranscriptIngestion: boolean
): { config: TranscriptWatchConfig; removed: number } {
if (allowCodexTranscriptIngestion) {
return { config, removed: 0 };
}
const watches = config.watches.filter(watch => !isNativeHookBackedCodexWatch(watch));
return {
config: {
...config,
watches,
},
removed: config.watches.length - watches.length,
};
}
export function expandHomePath(inputPath: string): string {
if (!inputPath) return inputPath;
if (inputPath.startsWith('~')) {
+4 -5
View File
@@ -122,7 +122,7 @@ export class TranscriptWatcher {
const files = this.resolveWatchFiles(resolvedPath);
for (const filePath of files) {
await this.addTailer(filePath, watch, schema, true);
await this.addTailer(filePath, watch, schema);
}
const watchRoot = this.deepestNonGlobAncestor(resolvedPath);
@@ -143,7 +143,7 @@ export class TranscriptWatcher {
const matches = this.resolveWatchFiles(resolvedPath);
for (const filePath of matches) {
if (!this.tailers.has(filePath)) {
void this.addTailer(filePath, watch, schema, false);
void this.addTailer(filePath, watch, schema);
}
}
});
@@ -223,15 +223,14 @@ export class TranscriptWatcher {
private async addTailer(
filePath: string,
watch: WatchTarget,
schema: TranscriptSchema,
initialDiscovery: boolean
schema: TranscriptSchema
): Promise<void> {
if (this.tailers.has(filePath)) return;
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
let offset = this.state.offsets[filePath] ?? 0;
if (offset === 0 && watch.startAtEnd && initialDiscovery) {
if (offset === 0 && watch.startAtEnd) {
try {
offset = statSync(filePath).size;
} catch (error: unknown) {
+20 -2
View File
@@ -81,7 +81,7 @@ import { TimelineService } from './worker/TimelineService.js';
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
import { SessionCompletionHandler } from './worker/session/SessionCompletionHandler.js';
import { setIngestContext, attachIngestGeneratorStarter } from './worker/http/shared.js';
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig } from './transcripts/config.js';
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, filterNativeHookBackedCodexWatches, loadTranscriptWatchConfig } from './transcripts/config.js';
import { TranscriptWatcher } from './transcripts/watcher.js';
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
@@ -471,9 +471,27 @@ export class WorkerService implements WorkerRef {
return;
}
const transcriptConfig = loadTranscriptWatchConfig(configPath);
const allowCodexTranscriptIngestion = settings.CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION === 'true';
const { config: transcriptConfig, removed } = filterNativeHookBackedCodexWatches(
loadTranscriptWatchConfig(configPath),
allowCodexTranscriptIngestion
);
const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH);
if (removed > 0) {
logger.warn('TRANSCRIPT', 'Skipped Codex transcript watch because native Codex hooks are authoritative', {
removed,
optInSetting: 'CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION=true',
});
}
if (transcriptConfig.watches.length === 0) {
logger.info('TRANSCRIPT', 'Transcript watcher config has no active watches; skipping automatic transcript capture', {
configPath: resolvedConfigPath,
});
return;
}
try {
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath);
await this.transcriptWatcher.start();