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;
}