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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user