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
+137
View File
@@ -0,0 +1,137 @@
import { existsSync, statSync } from 'fs';
import path from 'path';
import { parse, type ParsedToken } from 'shell-quote';
const MAX_FILE_PATHS = 10;
const READ_COMMANDS = new Set(['cat', 'head', 'tail', 'less', 'more', 'bat', 'view', 'nl', 'tac']);
const FLAGS_WITH_VALUES_BY_COMMAND: Record<string, Set<string>> = {
head: new Set(['-n', '-c', '--lines', '--bytes']),
tail: new Set(['-n', '-c', '--lines', '--bytes']),
};
const NO_FLAGS_WITH_VALUES = new Set<string>();
function isOperatorToken(token: ParsedToken): boolean {
return typeof token === 'object' && token !== null && 'op' in token;
}
function splitSegments(tokens: ParsedToken[]): string[][] {
const segments: string[][] = [];
let current: string[] = [];
for (const token of tokens) {
if (isOperatorToken(token)) {
if (current.length > 0) segments.push(current);
current = [];
continue;
}
if (typeof token === 'string') {
current.push(token);
}
}
if (current.length > 0) segments.push(current);
return segments;
}
function normalizeCommand(command: unknown): string | null {
if (typeof command === 'string') return command;
if (Array.isArray(command)) {
const parts = command.filter((part): part is string => typeof part === 'string');
return parts.length > 0 ? parts.join(' ') : null;
}
return null;
}
function isFlagLike(value: string): boolean {
return value.startsWith('-') || value.startsWith('+');
}
function flagsWithValues(command: string): Set<string> {
return FLAGS_WITH_VALUES_BY_COMMAND[command] ?? NO_FLAGS_WITH_VALUES;
}
function dropFlagValue(flag: string, command: string): boolean {
const valueFlags = flagsWithValues(command);
if (valueFlags.has(flag)) return true;
const eqIndex = flag.indexOf('=');
return eqIndex > 0 && valueFlags.has(flag.slice(0, eqIndex));
}
function isExistingFile(candidate: string, cwd: string): boolean {
const absolutePath = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
try {
if (!existsSync(absolutePath)) return false;
return statSync(absolutePath).isFile();
} catch {
return false;
}
}
function dedupeAndCap(paths: string[]): string[] {
const seen = new Set<string>();
const deduped: string[] = [];
for (const filePath of paths) {
if (seen.has(filePath)) continue;
seen.add(filePath);
deduped.push(filePath);
if (deduped.length >= MAX_FILE_PATHS) break;
}
return deduped;
}
function extractFromBash(toolInput: unknown, cwd: string): string[] {
const command = normalizeCommand((toolInput as { command?: unknown } | undefined)?.command);
if (!command) return [];
const tokens = parse(command);
const paths: string[] = [];
for (const segment of splitSegments(tokens)) {
const argv0Index = segment.findIndex(token => token && !isFlagLike(token));
if (argv0Index === -1) continue;
const argv0 = path.basename(segment[argv0Index]);
if (!READ_COMMANDS.has(argv0)) continue;
let skipNext = false;
for (const token of segment.slice(argv0Index + 1)) {
if (skipNext) {
skipNext = false;
continue;
}
if (isFlagLike(token)) {
skipNext = dropFlagValue(token, argv0) && !token.includes('=');
continue;
}
if (isExistingFile(token, cwd)) {
paths.push(token);
}
}
}
return dedupeAndCap(paths);
}
function extractFromMcp(toolName: string, toolInput: unknown, cwd: string): string[] {
if (!/^mcp__.+__(read|view|cat)(?:_file|_files)?$/.test(toolName)) return [];
const input = (toolInput ?? {}) as { path?: unknown; paths?: unknown };
const candidates: string[] = [];
if (typeof input.path === 'string') candidates.push(input.path);
if (Array.isArray(input.paths)) {
for (const item of input.paths) {
if (typeof item === 'string') candidates.push(item);
}
}
return dedupeAndCap(candidates.filter(candidate => isExistingFile(candidate, cwd)));
}
export function extractFilePaths(toolName: string, toolInput: unknown, cwd: string): string[] {
if (toolName === 'Bash') return extractFromBash(toolInput, cwd);
if (toolName.startsWith('mcp__')) return extractFromMcp(toolName, toolInput, cwd);
return [];
}
+139
View File
@@ -0,0 +1,139 @@
import type { HookResult, NormalizedHookInput, PlatformAdapter } from '../types.js';
import { AdapterRejectedInput, isValidCwd } from './errors.js';
import { extractFilePaths } from './codex-file-context.js';
type CodexEventName =
| 'PreToolUse'
| 'PermissionRequest'
| 'PostToolUse'
| 'SessionStart'
| 'UserPromptSubmit'
| 'Stop';
const EVENT_NAMES = new Set<CodexEventName>([
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'SessionStart',
'UserPromptSubmit',
'Stop',
]);
function eventName(value: unknown): CodexEventName | undefined {
return typeof value === 'string' && EVENT_NAMES.has(value as CodexEventName)
? value as CodexEventName
: undefined;
}
function stringOrUndefined(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function booleanOrUndefined(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
function cloneToolInput(toolInput: unknown): unknown {
if (toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
return { ...(toolInput as Record<string, unknown>) };
}
return toolInput;
}
function buildBaseOutput(result: HookResult): Record<string, unknown> {
const output: Record<string, unknown> = {};
if (result.continue !== undefined) output.continue = result.continue;
if (result.suppressOutput !== undefined) output.suppressOutput = result.suppressOutput;
if (result.systemMessage) output.systemMessage = result.systemMessage;
if (result.decision === 'block') output.decision = 'block';
if (result.reason) output.reason = result.reason;
return output;
}
function inferOutputEvent(result: HookResult): CodexEventName | undefined {
return eventName(result.hookSpecificOutput?.hookEventName);
}
export const codexAdapter: PlatformAdapter = {
normalizeInput(raw): NormalizedHookInput {
const r = (raw ?? {}) as Record<string, unknown>;
const cwd = typeof r.cwd === 'string' ? r.cwd : process.cwd();
if (!isValidCwd(cwd)) {
throw new AdapterRejectedInput('invalid_cwd');
}
const hookEventName = eventName(r.hook_event_name);
const toolName = stringOrUndefined(r.tool_name);
let toolInput = cloneToolInput(r.tool_input);
if (hookEventName === 'PreToolUse' && toolName) {
const filePaths = extractFilePaths(toolName, toolInput, cwd);
if (filePaths.length > 0 && toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
toolInput = { ...(toolInput as Record<string, unknown>), filePaths };
}
}
const source = r.source;
const sessionSource =
source === 'startup' || source === 'resume' || source === 'clear'
? source
: undefined;
const sessionId = stringOrUndefined(r.session_id);
if (!sessionId) {
throw new AdapterRejectedInput('missing_session_id');
}
return {
sessionId,
cwd,
prompt: stringOrUndefined(r.prompt),
toolName,
toolInput,
toolResponse: r.tool_response,
transcriptPath: stringOrUndefined(r.transcript_path),
lastAssistantMessage: stringOrUndefined(r.last_assistant_message),
turnId: stringOrUndefined(r.turn_id),
stopHookActive: booleanOrUndefined(r.stop_hook_active),
permissionMode: stringOrUndefined(r.permission_mode),
model: stringOrUndefined(r.model),
sessionSource,
};
},
formatOutput(result): unknown {
const r = result ?? {};
const output = buildBaseOutput(r);
const hookSpecific = r.hookSpecificOutput;
const outputEvent = inferOutputEvent(r);
if (!hookSpecific || !outputEvent || outputEvent === 'Stop') {
return output;
}
const specific: Record<string, unknown> = {
hookEventName: outputEvent,
};
if (hookSpecific.additionalContext) {
specific.additionalContext = hookSpecific.additionalContext;
}
if (outputEvent === 'PreToolUse') {
if (hookSpecific.permissionDecision === 'deny') {
specific.permissionDecision = 'deny';
if (hookSpecific.permissionDecisionReason) {
specific.permissionDecisionReason = hookSpecific.permissionDecisionReason;
}
}
if (hookSpecific.updatedInput) {
specific.updatedInput = hookSpecific.updatedInput;
}
}
output.hookSpecificOutput = specific;
return output;
},
};
+3 -1
View File
@@ -1,5 +1,6 @@
import type { PlatformAdapter } from '../types.js';
import { claudeCodeAdapter } from './claude-code.js';
import { codexAdapter } from './codex.js';
import { cursorAdapter } from './cursor.js';
import { geminiCliAdapter } from './gemini-cli.js';
import { rawAdapter } from './raw.js';
@@ -8,6 +9,7 @@ import { windsurfAdapter } from './windsurf.js';
export function getPlatformAdapter(platform: string): PlatformAdapter {
switch (platform) {
case 'claude-code': return claudeCodeAdapter;
case 'codex': return codexAdapter;
case 'cursor': return cursorAdapter;
case 'gemini':
case 'gemini-cli': return geminiCliAdapter;
@@ -17,4 +19,4 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
}
}
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
export { claudeCodeAdapter, codexAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
+85 -58
View File
@@ -13,6 +13,7 @@ const FILE_READ_GATE_MIN_BYTES = 1_500;
const FETCH_LOOKAHEAD_LIMIT = 40;
const DISPLAY_LIMIT = 15;
const MAX_FILE_CONTEXT_PATHS = 10;
const TYPE_ICONS: Record<string, string> = {
decision: '\u2696\uFE0F',
@@ -135,86 +136,112 @@ function formatFileTimeline(
export const fileContextHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
const toolInput = input.toolInput as Record<string, unknown> | undefined;
const filePaths = Array.isArray(toolInput?.filePaths)
? (toolInput.filePaths as unknown[]).filter((p): p is string => typeof p === 'string').slice(0, MAX_FILE_CONTEXT_PATHS)
: [];
const filePath = toolInput?.file_path as string | undefined;
const candidatePaths = filePaths.length > 0 ? filePaths : (filePath ? [filePath] : []);
if (!filePath) {
if (candidatePaths.length === 0) {
return { continue: true, suppressOutput: true };
}
let fileMtimeMs = 0;
try {
const statPath = path.isAbsolute(filePath)
? filePath
: path.resolve(input.cwd || process.cwd(), filePath);
const stat = statSync(statPath);
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
return { continue: true, suppressOutput: true };
}
fileMtimeMs = stat.mtimeMs;
} catch (err) {
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
return { continue: true, suppressOutput: true };
}
logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) });
}
if (input.cwd && !shouldTrackProject(input.cwd)) {
logger.debug('HOOK', 'Project excluded from tracking, skipping file context', { cwd: input.cwd });
return { continue: true, suppressOutput: true };
}
const context = getProjectContext(input.cwd);
const cwd = input.cwd || process.cwd();
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
const queryParams = new URLSearchParams({ path: relativePath });
if (context.allProjects.length > 0) {
queryParams.set('projects', context.allProjects.join(','));
}
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
const result = await executeWithWorkerFallback<{ observations: ObservationRow[]; count: number }>(
`/api/observations/by-file?${queryParams.toString()}`,
'GET',
const timelineResults = await Promise.allSettled(
candidatePaths.map(candidatePath => buildFileContextTimeline(input, candidatePath))
);
if (isWorkerFallback(result)) {
return { continue: true, suppressOutput: true };
}
if (!result || !Array.isArray((result as any).observations)) {
logger.warn('HOOK', 'File context query returned malformed body, skipping', { filePath });
return { continue: true, suppressOutput: true };
}
const data = result;
const timelines: string[] = [];
if (!data.observations || data.observations.length === 0) {
return { continue: true, suppressOutput: true };
}
if (fileMtimeMs > 0) {
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
if (fileMtimeMs >= newestObservationMs) {
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
filePath: relativePath,
fileMtimeMs,
newestObservationMs,
});
return { continue: true, suppressOutput: true };
timelineResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
if (result.value) timelines.push(result.value);
return;
}
}
logger.debug('HOOK', 'File context timeline lookup failed, skipping path', {
filePath: candidatePaths[index],
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
});
});
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
if (dedupedObservations.length === 0) {
if (timelines.length === 0) {
return { continue: true, suppressOutput: true };
}
const timeline = formatFileTimeline(dedupedObservations, filePath);
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: timeline,
additionalContext: timelines.join('\n\n---\n\n'),
permissionDecision: 'allow',
},
};
},
};
async function buildFileContextTimeline(input: NormalizedHookInput, filePath: string): Promise<string | null> {
let fileMtimeMs = 0;
try {
const statPath = path.isAbsolute(filePath)
? filePath
: path.resolve(input.cwd || process.cwd(), filePath);
const stat = statSync(statPath);
if (!stat.isFile() || stat.size < FILE_READ_GATE_MIN_BYTES) {
return null;
}
fileMtimeMs = stat.mtimeMs;
} catch (err) {
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) });
}
const context = getProjectContext(input.cwd);
const cwd = input.cwd || process.cwd();
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
const queryParams = new URLSearchParams({ path: relativePath });
if (context.allProjects.length > 0) {
queryParams.set('projects', context.allProjects.join(','));
}
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
const result = await executeWithWorkerFallback<{ observations: ObservationRow[]; count: number }>(
`/api/observations/by-file?${queryParams.toString()}`,
'GET',
);
if (isWorkerFallback(result)) {
return null;
}
if (!result || !Array.isArray((result as any).observations)) {
logger.warn('HOOK', 'File context query returned malformed body, skipping', { filePath });
return null;
}
const data = result;
if (!data.observations || data.observations.length === 0) {
return null;
}
if (fileMtimeMs > 0) {
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
if (fileMtimeMs >= newestObservationMs) {
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
filePath: relativePath,
fileMtimeMs,
newestObservationMs,
});
return null;
}
}
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
if (dedupedObservations.length === 0) {
return null;
}
return formatFileTimeline(dedupedObservations, filePath);
}
+25 -12
View File
@@ -14,6 +14,13 @@ export const summarizeHandler: EventHandler = {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
if (input.stopHookActive === true) {
logger.debug('HOOK', 'Skipping summary: Codex Stop hook re-entry detected', {
sessionId: input.sessionId,
});
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
if (input.agentId) {
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
sessionId: input.sessionId,
@@ -29,22 +36,28 @@ export const summarizeHandler: EventHandler = {
logger.warn('HOOK', 'summarize: No sessionId provided, skipping');
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
if (!transcriptPath) {
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
let lastAssistantMessage = '';
try {
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
lastAssistantMessage = stripMemoryTagsFromPrompt(lastAssistantMessage);
} catch (err) {
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
if (input.lastAssistantMessage !== undefined) {
lastAssistantMessage = stripMemoryTagsFromPrompt(input.lastAssistantMessage);
} else {
if (!transcriptPath) {
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
try {
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
lastAssistantMessage = stripMemoryTagsFromPrompt(lastAssistantMessage);
} catch (err) {
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
}
if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
logger.debug('HOOK', 'No assistant message in transcript - skipping summary', {
logger.debug('HOOK', 'No assistant message available - skipping summary', {
sessionId,
transcriptPath
});
@@ -71,6 +84,6 @@ export const summarizeHandler: EventHandler = {
}
logger.debug('HOOK', 'Summary request queued, exiting hook');
return { continue: true, suppressOutput: true };
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
},
};
+9
View File
@@ -7,6 +7,12 @@ export interface NormalizedHookInput {
toolInput?: unknown;
toolResponse?: unknown;
transcriptPath?: string;
lastAssistantMessage?: string;
turnId?: string;
stopHookActive?: boolean;
permissionMode?: string;
model?: string;
sessionSource?: 'startup' | 'resume' | 'clear';
filePath?: string;
edits?: unknown[];
metadata?: Record<string, unknown>;
@@ -21,9 +27,12 @@ export interface HookResult {
hookEventName: string;
additionalContext: string;
permissionDecision?: 'allow' | 'deny';
permissionDecisionReason?: string;
updatedInput?: Record<string, unknown>;
};
systemMessage?: string;
decision?: 'block' | 'approve';
reason?: string;
exitCode?: number;
}
+1 -2
View File
@@ -80,7 +80,7 @@ export function detectInstalledIDEs(): IDEInfo[] {
label: 'Codex CLI',
detected: existsSync(join(home, '.codex')),
supported: true,
hint: 'transcript-based integration',
hint: 'native hooks integration',
},
{
id: 'cursor',
@@ -127,4 +127,3 @@ export function detectInstalledIDEs(): IDEInfo[] {
},
];
}
+7 -4
View File
@@ -243,17 +243,17 @@ function makeIDETask(ideId: string, failedIDEs: string[], pendingErrors: string[
case 'codex-cli': {
return {
title: 'Codex CLI: configuring transcript watching',
title: 'Codex CLI: registering hooks marketplace',
task: async (message) => {
message('Loading Codex CLI installer…');
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
message('Configuring transcript watching…');
const { result, output } = await bufferConsole(() => installCodexCli());
message('Registering native Codex hooks…');
const { result, output } = await bufferConsole(() => installCodexCli(marketplaceDirectory()));
if (result !== 0) {
recordFailure('Codex CLI: integration setup failed', output);
return `Codex CLI: integration setup failed ${pc.red('FAIL')}`;
}
return `Codex CLI: transcript watching configured ${pc.green('OK')}`;
return `Codex CLI: hooks marketplace registered ${pc.green('OK')}`;
},
};
}
@@ -500,6 +500,9 @@ function copyPluginToMarketplace(): void {
ensureDirectoryExists(marketplaceDir);
const allowedTopLevelEntries = [
'.agents',
'.codex-plugin',
'.mcp.json',
'plugin',
'package.json',
'package-lock.json',
+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);
}
+2
View File
@@ -1,6 +1,8 @@
export const HOOK_TIMEOUTS = {
DEFAULT: 300000, // Standard HTTP timeout (5 min for slow systems)
HEALTH_CHECK: 3000, // Worker health check (3s — healthy worker responds in <100ms)
API_REQUEST: 30000, // Hook API calls should outlive health probes but stay below hook caps
HOOK_READINESS_WAIT: 10000, // Per-hook wait for an already-starting worker to finish DB/search init
POST_SPAWN_WAIT: 15000, // Wait for daemon to start after spawn (starts in <1s on Linux, 6-8s on macOS with Chroma)
READINESS_WAIT: 30000, // Wait for DB + search init after spawn (typically <5s)
PORT_IN_USE_WAIT: 3000, // Wait when port occupied but health failing
+83 -9
View File
@@ -9,19 +9,41 @@ import { MARKETPLACE_ROOT, DATA_DIR } from "./paths.js";
import { loadFromFileOnce } from "./hook-settings.js";
import { validateWorkerPidFile } from "../supervisor/index.js";
const HEALTH_CHECK_TIMEOUT_MS = (() => {
const envVal = process.env.CLAUDE_MEM_HEALTH_TIMEOUT_MS;
function readTimeoutEnv(
envName: string,
defaultValue: number,
bounds: { min: number; max: number }
): number {
const envVal = process.env[envName];
if (envVal) {
const parsed = parseInt(envVal, 10);
if (Number.isFinite(parsed) && parsed >= 500 && parsed <= 300000) {
if (Number.isFinite(parsed) && parsed >= bounds.min && parsed <= bounds.max) {
return parsed;
}
logger.warn('SYSTEM', 'Invalid CLAUDE_MEM_HEALTH_TIMEOUT_MS, using default', {
value: envVal, min: 500, max: 300000
logger.warn('SYSTEM', `Invalid ${envName}, using default`, {
value: envVal, min: bounds.min, max: bounds.max
});
}
return getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
})();
return defaultValue;
}
const HEALTH_CHECK_TIMEOUT_MS = readTimeoutEnv(
'CLAUDE_MEM_HEALTH_TIMEOUT_MS',
getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK),
{ min: 500, max: 300000 }
);
const API_REQUEST_TIMEOUT_MS = readTimeoutEnv(
'CLAUDE_MEM_API_TIMEOUT_MS',
getTimeout(HOOK_TIMEOUTS.API_REQUEST),
{ min: 500, max: 300000 }
);
const HOOK_READINESS_TIMEOUT_MS = readTimeoutEnv(
'CLAUDE_MEM_HOOK_READINESS_TIMEOUT_MS',
getTimeout(HOOK_TIMEOUTS.HOOK_READINESS_WAIT),
{ min: 0, max: 300000 }
);
export function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs: number): Promise<Response> {
return new Promise((resolve, reject) => {
@@ -80,7 +102,7 @@ export function workerHttpRequest(
} = {}
): Promise<Response> {
const method = options.method ?? 'GET';
const timeoutMs = options.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
const timeoutMs = options.timeoutMs ?? API_REQUEST_TIMEOUT_MS;
const url = buildWorkerUrl(apiPath);
const init: RequestInit = { method };
@@ -102,6 +124,11 @@ async function isWorkerHealthy(): Promise<boolean> {
return response.ok;
}
async function isWorkerReady(): Promise<boolean> {
const response = await workerHttpRequest('/api/readiness', { timeoutMs: HEALTH_CHECK_TIMEOUT_MS });
return response.ok;
}
function getPluginVersion(): string {
try {
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
@@ -203,6 +230,32 @@ async function waitForWorkerPort(options: { attempts: number; backoffMs: number
return false;
}
async function waitForWorkerReadiness(timeoutMs: number = HOOK_READINESS_TIMEOUT_MS): Promise<boolean> {
if (timeoutMs <= 0) {
try {
return await isWorkerReady();
} catch {
return false;
}
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
if (await isWorkerReady()) return true;
} catch (error: unknown) {
logger.debug('SYSTEM', 'Worker readiness check threw', {
error: error instanceof Error ? error.message : String(error),
});
}
const remainingMs = timeoutMs - (Date.now() - start);
if (remainingMs <= 0) break;
await new Promise<void>(resolve => setTimeout(resolve, Math.min(250, remainingMs)));
}
return false;
}
async function isWorkerPortAlive(): Promise<boolean> {
let healthy: boolean;
try {
@@ -224,6 +277,11 @@ async function isWorkerPortAlive(): Promise<boolean> {
export async function ensureWorkerRunning(): Promise<boolean> {
if (await isWorkerPortAlive()) {
await checkWorkerVersion();
const ready = await waitForWorkerReadiness();
if (!ready) {
logger.warn('SYSTEM', 'Worker is healthy but not ready; skipping hook API call');
return false;
}
return true;
}
@@ -263,6 +321,11 @@ export async function ensureWorkerRunning(): Promise<boolean> {
logger.warn('SYSTEM', 'Worker port did not open after lazy-spawn within 3 attempts');
return false;
}
const ready = await waitForWorkerReadiness();
if (!ready) {
logger.warn('SYSTEM', 'Worker lazy-spawned but did not become ready before hook readiness timeout');
return false;
}
return true;
}
@@ -400,8 +463,19 @@ export async function executeWithWorkerFallback<T = unknown>(
const response = await workerHttpRequest(url, init);
if (!response.ok) {
resetWorkerFailureCounter();
const text = await response.text().catch(() => '');
resetWorkerFailureCounter();
if (response.status === 429 || response.status >= 500) {
logger.warn('SYSTEM', `Worker API ${method} ${url} returned ${response.status}; skipping hook API call`, {
body: text.substring(0, 200),
});
return {
continue: true,
reason: `worker_api_${response.status}`,
[WORKER_FALLBACK_BRAND]: true,
};
}
let parsed: unknown = text;
try { parsed = JSON.parse(text); } catch { /* keep raw text */ }
return parsed as T;
+4
View File
@@ -0,0 +1,4 @@
declare module 'shell-quote' {
export type ParsedToken = string | { op: string } | Record<string, unknown>;
export function parse(command: string): ParsedToken[];
}