Add native Codex hooks integration (#2319)

* Add native Codex hooks integration

* Address Codex review feedback

* Use durable Codex marketplace root

* Address Codex file context review feedback

* Harden Codex installer review paths

* Report Codex legacy cleanup failures

* fix: keep MCP manifests in marketplace sync

* fix: bundle zod in MCP server

* fix: warn on Codex legacy cleanup failure

* Fix hook observation readiness timeouts

* Address Codex hook review notes

* Tighten Codex MCP file context matching

* Resolve final Codex review nits

* Add Codex marketplace version guidance

* Reset worker failure counter on API fallback

* Fix Codex cat flag file extraction
This commit is contained in:
Alex Newman
2026-05-06 01:55:27 -07:00
committed by GitHub
parent a5bb6b346a
commit 56db06811e
33 changed files with 1628 additions and 504 deletions
+175 -119
View File
@@ -1,99 +1,151 @@
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { execFileSync, spawnSync } from 'child_process';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { logger } from '../../utils/logger.js';
import { replaceTaggedContent } from '../../utils/claude-md-utils.js';
import {
DEFAULT_CONFIG_PATH,
DEFAULT_STATE_PATH,
SAMPLE_CONFIG,
} from '../transcripts/config.js';
import { paths } from '../../shared/paths.js';
import type { TranscriptWatchConfig, WatchTarget } from '../transcripts/types.js';
const CODEX_DIR = path.join(homedir(), '.codex');
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
const CLAUDE_MEM_DIR = paths.dataDir();
const CODEX_WATCH_NAME = 'codex';
function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
const configPath = DEFAULT_CONFIG_PATH;
if (!existsSync(configPath)) {
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
}
const MARKETPLACE_NAME = 'claude-mem-local';
const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0';
const REQUIRED_MARKETPLACE_FILES = [
path.join('.agents', 'plugins', 'marketplace.json'),
path.join('.codex-plugin', 'plugin.json'),
'.mcp.json',
];
function commandExists(command: string): boolean {
try {
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
if (!parsed.version) parsed.version = 1;
if (!parsed.watches) parsed.watches = [];
if (!parsed.schemas) parsed.schemas = {};
if (!parsed.stateFile) parsed.stateFile = DEFAULT_STATE_PATH;
return parsed;
} catch (parseError) {
if (parseError instanceof Error) {
logger.error('WORKER', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError);
if (process.platform === 'win32') {
execFileSync('where', [command], { stdio: 'ignore' });
} else {
logger.error('WORKER', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, new Error(String(parseError)));
execFileSync('which', [command], { stdio: 'ignore' });
}
const backupPath = `${configPath}.backup.${Date.now()}`;
writeFileSync(backupPath, readFileSync(configPath));
console.warn(` Backed up corrupt transcript-watch.json to ${backupPath}`);
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
return true;
} catch {
return false;
}
}
function mergeCodexWatchConfig(existingConfig: TranscriptWatchConfig): TranscriptWatchConfig {
const merged = { ...existingConfig };
merged.schemas = { ...merged.schemas };
const codexSchema = SAMPLE_CONFIG.schemas?.[CODEX_WATCH_NAME];
if (codexSchema) {
merged.schemas[CODEX_WATCH_NAME] = codexSchema;
}
const codexWatchFromSample = SAMPLE_CONFIG.watches.find(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
if (codexWatchFromSample) {
const existingWatchIndex = merged.watches.findIndex(
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
);
if (existingWatchIndex !== -1) {
merged.watches[existingWatchIndex] = codexWatchFromSample;
} else {
merged.watches.push(codexWatchFromSample);
function findAncestorWithCodexPlugin(start: string): string | null {
let current = path.resolve(start);
while (true) {
if (existsSync(path.join(current, '.codex-plugin', 'plugin.json'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
function missingMarketplaceFiles(root: string): string[] {
return REQUIRED_MARKETPLACE_FILES.filter((entry) => !existsSync(path.join(root, entry)));
}
function assertCodexMarketplaceRoot(root: string): string {
const resolved = path.resolve(root);
const missing = missingMarketplaceFiles(resolved);
if (missing.length > 0) {
throw new Error(`Codex marketplace root ${resolved} is missing required files: ${missing.join(', ')}`);
}
return resolved;
}
function resolvePluginMarketplaceRoot(preferredRoot?: string): string {
if (preferredRoot) {
return assertCodexMarketplaceRoot(preferredRoot);
}
return merged;
const candidates = [
process.env.CLAUDE_PLUGIN_ROOT,
process.env.PLUGIN_ROOT,
process.cwd(),
path.dirname(fileURLToPath(import.meta.url)),
].filter((value): value is string => Boolean(value));
for (const candidate of candidates) {
const resolved = findAncestorWithCodexPlugin(candidate);
if (resolved && missingMarketplaceFiles(resolved).length === 0) return resolved;
}
throw new Error('Could not locate a Codex marketplace root with .agents/plugins/marketplace.json, .codex-plugin/plugin.json, and .mcp.json. Run npx claude-mem@latest install from the package or repo root.');
}
function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
mkdirSync(CLAUDE_MEM_DIR, { recursive: true });
writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
function runCodex(args: string[]): void {
const result = spawnSync('codex', args, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const output = console;
const stdout = result.stdout?.trimEnd();
const stderr = result.stderr?.trimEnd();
if (stdout) output.log(stdout);
if (stderr) output.error(stderr);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const exitCode = result.status ?? 'unknown';
throw new Error(`codex ${args.join(' ')} failed with exit code ${exitCode}${stderr ? `: ${stderr}` : ''}`);
}
}
function removeCodexAgentsMdContext(): void {
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
function parseSemver(value: string): [number, number, number] | null {
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareSemver(left: [number, number, number], right: [number, number, number]): number {
if (left[0] !== right[0]) return left[0] - right[0];
if (left[1] !== right[1]) return left[1] - right[1];
return left[2] - right[2];
}
function assertCodexMarketplaceSupported(): void {
const result = spawnSync('codex', ['--version'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
console.warn(` Could not determine Codex CLI version. Continuing; plugin marketplace support requires ${MIN_CODEX_MARKETPLACE_VERSION} or newer.${output ? `\n${output}` : ''}`);
return;
}
const version = parseSemver(output);
if (!version) {
console.warn(` Could not parse Codex CLI version from "${output || '<empty>'}". Continuing; plugin marketplace support requires ${MIN_CODEX_MARKETPLACE_VERSION} or newer.`);
return;
}
const minimumVersion = parseSemver(MIN_CODEX_MARKETPLACE_VERSION);
if (minimumVersion && compareSemver(version, minimumVersion) < 0) {
throw new Error(`Codex CLI ${version.join('.')} is too old for plugin marketplace support. Update Codex CLI to ${MIN_CODEX_MARKETPLACE_VERSION} or newer, then run: npx claude-mem@latest install`);
}
}
function removeCodexAgentsMdContext(): boolean {
if (!existsSync(CODEX_AGENTS_MD_PATH)) return true;
const startTag = '<claude-mem-context>';
const endTag = '</claude-mem-context>';
try {
readAndStripContextTags(startTag, endTag);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn('WORKER', 'Failed to clean AGENTS.md context', { error: message });
return false;
}
}
@@ -120,14 +172,39 @@ function readAndStripContextTags(startTag: string, endTag: string): void {
const cleanupLegacyCodexAgentsMdContext = removeCodexAgentsMdContext;
export async function installCodexCli(): Promise<number> {
console.log('\nInstalling Claude-Mem for Codex CLI (transcript watching)...\n');
export async function installCodexCli(marketplaceRootOverride?: string): Promise<number> {
console.log('\nInstalling Claude-Mem for Codex CLI (native hooks)...\n');
const existingConfig = loadExistingTranscriptWatchConfig();
const mergedConfig = mergeCodexWatchConfig(existingConfig);
if (!commandExists('codex')) {
console.error('Codex CLI was not found on PATH.');
console.error('Install Codex, then run: npx claude-mem@latest install');
return 1;
}
try {
writeConfigAndShowCodexInstructions(mergedConfig);
assertCodexMarketplaceSupported();
const marketplaceRoot = resolvePluginMarketplaceRoot(marketplaceRootOverride);
console.log(` Registering Codex plugin marketplace: ${marketplaceRoot}`);
runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]);
if (!cleanupLegacyCodexAgentsMdContext()) {
console.warn(` Native Codex hooks registered, but failed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`);
}
console.log(`
Installation complete!
Codex marketplace: ${MARKETPLACE_NAME}
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
For a fresh setup, the supported entry point is:
npx claude-mem@latest install
`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -136,62 +213,41 @@ export async function installCodexCli(): Promise<number> {
}
}
function writeConfigAndShowCodexInstructions(mergedConfig: TranscriptWatchConfig): void {
writeTranscriptWatchConfig(mergedConfig);
console.log(` Updated ${DEFAULT_CONFIG_PATH}`);
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
cleanupLegacyCodexAgentsMdContext();
console.log(`
Installation complete!
Transcript watch config: ${DEFAULT_CONFIG_PATH}
Context files: <workspace>/AGENTS.md
How it works:
- claude-mem watches Codex session JSONL files for new activity
- No hooks needed -- transcript watching is fully automatic
- Context from past sessions is injected via AGENTS.md in the active Codex workspace
Next steps:
1. Start claude-mem worker: npx claude-mem start
2. Use Codex CLI as usual -- memory capture is automatic!
`);
}
export function uninstallCodexCli(): number {
console.log('\nUninstalling Claude-Mem Codex CLI integration...\n');
if (existsSync(DEFAULT_CONFIG_PATH)) {
const config = loadExistingTranscriptWatchConfig();
let failed = false;
config.watches = config.watches.filter(
(w: WatchTarget) => w.name !== CODEX_WATCH_NAME,
);
if (config.schemas) {
delete config.schemas[CODEX_WATCH_NAME];
try {
if (commandExists('codex')) {
runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
} else {
console.log(' Codex CLI not found; skipping marketplace removal.');
}
try {
writeTranscriptWatchConfig(config);
console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`\nUninstallation failed: ${message}`);
return 1;
}
} else {
console.log(' No transcript-watch.json found -- nothing to remove.');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`\nCodex marketplace removal failed: ${message}`);
failed = true;
}
cleanupLegacyCodexAgentsMdContext();
try {
if (!cleanupLegacyCodexAgentsMdContext()) {
console.error(`\nFailed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`);
failed = true;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`\nLegacy AGENTS.md cleanup failed: ${message}`);
failed = true;
}
if (failed) {
console.error('\nUninstallation completed with errors.');
return 1;
}
console.log('\nUninstallation complete!');
console.log('Restart claude-mem worker to apply changes.\n');
console.log('Restart Codex CLI to apply changes.\n');
return 0;
}
+12 -28
View File
@@ -71,7 +71,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, writeSampleConfig } from './transcripts/config.js';
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig } from './transcripts/config.js';
import { TranscriptWatcher } from './transcripts/watcher.js';
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
@@ -128,9 +128,7 @@ export class WorkerService implements WorkerRef {
private searchRoutes: SearchRoutes | null = null;
private chromaMcpManager: ChromaMcpManager | null = null;
private transcriptWatcher: TranscriptWatcher | null = null;
private initializationComplete: Promise<void>;
private resolveInitialization!: () => void;
@@ -237,26 +235,12 @@ export class WorkerService implements WorkerRef {
return;
}
const timeoutMs = 120000;
const timeoutPromise = new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error('Database initialization timeout')), timeoutMs)
);
try {
await Promise.race([this.initializationComplete, timeoutPromise]);
next();
} catch (error) {
if (error instanceof Error) {
logger.error('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized`, {}, error);
} else {
logger.error('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized with non-Error`, {}, new Error(String(error)));
}
res.status(503).json({
error: 'Service initializing',
message: 'Database is still initializing, please retry'
});
return;
}
logger.debug('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized`);
res.status(503).json({
error: 'Service initializing',
message: 'Database is still initializing, please retry'
});
return;
});
this.server.registerRoutes(new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager));
@@ -461,10 +445,10 @@ export class WorkerService implements WorkerRef {
const resolvedConfigPath = expandHomePath(configPath);
if (!existsSync(resolvedConfigPath)) {
writeSampleConfig(configPath);
logger.info('TRANSCRIPT', 'Created default transcript watch config', {
logger.info('TRANSCRIPT', 'Transcript watcher config not found; skipping automatic transcript capture', {
configPath: resolvedConfigPath
});
return;
}
const transcriptConfig = loadTranscriptWatchConfig(configPath);
@@ -477,11 +461,11 @@ export class WorkerService implements WorkerRef {
this.transcriptWatcher?.stop();
this.transcriptWatcher = null;
if (error instanceof Error) {
logger.error('WORKER', 'Failed to start transcript watcher (continuing without Codex ingestion)', {
logger.error('WORKER', 'Failed to start transcript watcher (continuing without transcript ingestion)', {
configPath: resolvedConfigPath
}, error);
} else {
logger.error('WORKER', 'Failed to start transcript watcher with non-Error (continuing without Codex ingestion)', {
logger.error('WORKER', 'Failed to start transcript watcher with non-Error (continuing without transcript ingestion)', {
configPath: resolvedConfigPath
}, new Error(String(error)));
}
@@ -864,7 +848,7 @@ async function main() {
const event = process.argv[4];
if (!platform || !event) {
console.error('Usage: claude-mem hook <platform> <event>');
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
console.error('Platforms: claude-code, codex, cursor, gemini-cli, raw');
console.error('Events: context, session-init, observation, summarize, user-message');
process.exit(1);
}