Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 297 additions & 0 deletions docs/plans/2026-03-23-001-feat-codex-hook-conversion-beta-plan.md

Large diffs are not rendered by default.

160 changes: 160 additions & 0 deletions docs/solutions/integration-issues/codex-hook-converter-gap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
title: "Codex Hook Support: Cross-Platform Conversion Gap Analysis"
type: research
status: active
category: integration-issues
component: codex-converter
created: 2026-03-23
updated: 2026-03-23
affected_files:
- src/converters/claude-to-codex.ts
- src/types/codex.ts
- src/targets/codex.ts
- docs/specs/codex.md
tags:
- codex
- hooks
- converter
- cross-platform
- research
---

# Codex Hook Support: Cross-Platform Conversion Gap Analysis

## Problem Statement

The Codex converter (`src/converters/claude-to-codex.ts`) silently drops all hooks during Claude-to-Codex conversion with no warning emitted. Every other converter in the codebase (Windsurf, Kiro, Copilot, Gemini) at least emits `console.warn` for unsupported hooks. The `CodexBundle` type (`src/types/codex.ts`) has no `hooks` field, and the Codex spec (`docs/specs/codex.md`, last verified 2026-01-21) makes no mention of hooks.

Meanwhile, Codex has been incrementally shipping hook support across multiple PRs, creating a growing conversion gap.

## Investigation

### Step 1: Current Converter State

Checked `src/converters/claude-to-codex.ts` -- found zero references to hooks. The function `convertClaudeToCodex()` processes agents, commands, and skills but has no hook handling at all. No `console.warn`, no comment, nothing.

For comparison, every other converter follows a documented pattern from `docs/solutions/adding-converter-target-providers.md` (Phase 5):

```typescript
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
console.warn("Warning: {Target} does not support hooks. Hooks were skipped.")
}
```

### Step 2: Codex Upstream Hook Support Research

Researched four Codex PRs and the current main branch to map what Codex now supports:

#### PreToolUse -- MERGED ([openai/codex#15211](https://github.com/openai/codex/pull/15211))

- Shell/Bash only -- tool name hardcoded to `"Bash"` regardless of underlying tool (`shell`, `local_shell`, `shell_command`, `container.exec`, `exec_command`)
- Deny-only -- `permissionDecision: "deny"` is the only supported decision. `allow`, `ask`, `updatedInput`, `additionalContext` all fail open with an error log
- Matchers are regex patterns tested against tool name (but tool name is always "Bash")
- Two blocking paths: JSON output with `permissionDecision: "deny"`, or exit code 2 + stderr
- Key files: `codex-rs/hooks/src/events/pre_tool_use.rs`, `codex-rs/core/src/hook_runtime.rs`

#### PostToolUse -- DRAFT/WIP ([openai/codex#15531](https://github.com/openai/codex/pull/15531))

- Shell/Bash only (same as PreToolUse)
- Supports `block` decision + `additionalContext` injection
- `updatedMCPToolOutput` exists in the schema but is explicitly rejected at runtime
- Input includes `tool_response` (the command's output) in addition to `tool_input`
- `continue: false`, `stopReason`, `suppressOutput` all present in schema but unsupported
- Key files: `codex-rs/hooks/src/events/post_tool_use.rs`, `codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json`

#### UserPromptSubmit -- MERGED ([openai/codex#14626](https://github.com/openai/codex/pull/14626))

- Fires before user prompt reaches the model
- Supports `block` decision + `additionalContext` injection
- No matcher support -- all registered hooks fire for every prompt (unlike Claude Code which supports matchers)
- Blocked prompts never enter conversation history (differs from Claude Code where they may still be recorded)
- Context injected as developer messages (not user messages)
- Feature-gated behind `codex_hooks = true` in `config.toml` `[features]`
- Key files: `codex-rs/hooks/src/events/user_prompt_submit.rs`, `codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json`

#### SessionStart -- EXISTS ON MAIN

- Supports `SessionStartSource` enum with `Startup` and `Resume` variants
- Supports `additional_contexts` (context injection) and `should_stop`/`stop_reason`
- A community PR ([openai/codex#11637](https://github.com/openai/codex/pull/11637)) proposed this but was closed due to OpenAI's contribution policy change -- OpenAI implemented it themselves
- Key files: `codex-rs/hooks/src/events/session_start.rs`

#### Stop -- EXISTS ON MAIN

- Exists alongside SessionStart in the `HookEventName` enum
- Fires at session end

### Step 3: Hook Type Compatibility

Only `command` type hooks are supported in Codex. Claude Code's `prompt` and `agent` hook types have no equivalent.

## Hook Event Compatibility Matrix

| Claude Event | Codex Status | Convertible? | Key Limitations |
|---|---|---|---|
| PreToolUse (Bash matcher) | Merged (PR #15211) | Yes | Deny-only; no allow/ask/updatedInput/additionalContext |
| PreToolUse (non-Bash) | N/A | No | Codex only fires for shell tools |
| PostToolUse (Bash matcher) | Draft (PR #15531) | Yes (pending merge) | Block + additionalContext only |
| PostToolUse (non-Bash) | N/A | No | Codex only fires for shell tools |
| UserPromptSubmit | Merged (PR #14626) | Yes | No matchers; blocked prompts don't enter history |
| SessionStart | Exists on main | Yes | Command-only |
| Stop | Exists on main | Yes | Command-only |
| PostToolUseFailure | N/A | No | No Codex equivalent |
| PermissionRequest | N/A | No | No Codex equivalent |
| Notification | N/A | No | No Codex equivalent |
| SessionEnd | N/A | No | No Codex equivalent |
| PreCompact | N/A | No | No Codex equivalent |
| Setup | N/A | No | No Codex equivalent |
| SubagentStart/Stop | N/A | No | No Codex equivalent |

## Cross-Cutting Constraints

- **Command-only**: Only `type: "command"` hooks. No `prompt` or `agent` types in Codex.
- **Feature-gated**: Requires `codex_hooks = true` in `config.toml` under `[features]`.
- **Strict schema**: `deny_unknown_fields` on all wire types -- extra fields cause parse failure.
- **Fail-open design**: Unsupported outputs, malformed JSON, non-zero/non-2 exit codes all resolve to "allow."
- **No Windows support**: Entire hook system disabled on Windows.
- **No async hooks**: `async: true` in config is recognized but skipped with a warning.

## Semantic Differences from Claude Code

1. **PreToolUse decisions**: Claude Code supports allow/deny/ask/updatedInput/additionalContext. Codex supports deny only.
2. **UserPromptSubmit matchers**: Claude Code supports matchers to selectively fire hooks. Codex has no matcher support.
3. **UserPromptSubmit blocking**: In Claude Code, blocked prompts may still enter conversation history. In Codex, they never do.
4. **Context injection**: Codex injects additionalContext as developer messages, which may have different model-visible behavior.

## Recommended Solution

1. Add `hooks` field to `CodexBundle` type in `src/types/codex.ts`
2. Convert the compatible subset: PreToolUse (Bash-matched, command type), PostToolUse (pending PR #15531 merge), UserPromptSubmit, SessionStart, Stop
3. Skip + warn for unconvertible hooks with specific reasons (non-Bash matchers, prompt/agent types, unsupported events)
4. Write `hooks.json` output in the Codex writer (`src/targets/codex.ts`)
5. Update `docs/specs/codex.md` with hook documentation
6. Add converter and writer tests

## Prevention Strategies

### Silent feature drops

The core issue is that the Codex converter was never updated to even warn about hooks. Five converters (Codex, Droid, Pi, OpenClaw, Qwen) silently drop hooks. Consider:

- Adding a cross-converter completeness test that asserts every converter either includes hooks in its bundle or emits a warning
- Framework-level validation in `src/targets/index.ts` that automatically checks for dropped features post-conversion

### Tracking upstream changes

The Codex spec (`docs/specs/codex.md`) was last verified 2026-01-21. Consider a capability matrix that maps each Claude feature to each target's support status with `last-verified` dates, and flagging entries older than 90 days as stale during `release:validate`.

## Related Documentation

- `docs/specs/codex.md` -- Codex target spec (needs hooks section added)
- `docs/specs/claude-code.md` -- Claude Code hook architecture reference
- `docs/solutions/adding-converter-target-providers.md` -- Documented converter pattern (Phase 5 warns on unsupported features; Codex never implemented this)
- `docs/solutions/codex-skill-prompt-entrypoints.md` -- Codex-specific conversion patterns
- `src/converters/claude-to-opencode.ts` -- Reference for full hook conversion (the only converter that maps hooks today)
- Commit `598222e` -- OpenCode PreToolUse try-catch fix (issue #85)

## Refresh Candidates

- `docs/solutions/adding-converter-target-providers.md` may need update once Codex hook conversion is implemented, since the pattern of "all new targets warn-and-skip hooks" will no longer be universal
- `docs/specs/codex.md` needs a hooks section
114 changes: 86 additions & 28 deletions docs/specs/codex.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Codex Spec (Config, Prompts, Skills, MCP)

Last verified: 2026-01-21
Last verified: 2026-03-23

## Primary sources

Expand All @@ -16,46 +16,104 @@ https://developers.openai.com/codex/mcp

## Config location and precedence

- Codex reads local settings from `~/.codex/config.toml`, shared by the CLI and IDE extension. citeturn2view0
- Configuration precedence is: CLI flags profile values root-level values in `config.toml` built-in defaults. citeturn2view0
- Codex stores local state under `CODEX_HOME` (defaults to `~/.codex`) and includes `config.toml` there. citeturn4view0
- Codex reads local settings from `~/.codex/config.toml`, shared by the CLI and IDE extension. citeturn2view0
- Configuration precedence is: CLI flags -> profile values -> root-level values in `config.toml` -> built-in defaults. citeturn2view0
- Codex stores local state under `CODEX_HOME` (defaults to `~/.codex`) and includes `config.toml` there. citeturn4view0

## Profiles and providers

- Profiles are defined under `[profiles.<name>]` and selected with `codex --profile <name>`. citeturn4view0
- A top-level `profile = "<name>"` sets the default profile; CLI flags can override it. citeturn4view0
- Profiles are experimental and not supported in the IDE extension. citeturn4view0
- Custom model providers can be defined with base URL, wire API, and optional headers, then referenced via `model_provider`. citeturn4view0
- Profiles are defined under `[profiles.<name>]` and selected with `codex --profile <name>`. citeturn4view0
- A top-level `profile = "<name>"` sets the default profile; CLI flags can override it. citeturn4view0
- Profiles are experimental and not supported in the IDE extension. citeturn4view0
- Custom model providers can be defined with base URL, wire API, and optional headers, then referenced via `model_provider`. citeturn4view0

## Custom prompts (slash commands)

- Custom prompts are Markdown files stored under `~/.codex/prompts/`. citeturn3view0
- Custom prompts require explicit invocation and arent shared through the repository; use skills to share or auto-invoke. citeturn3view0
- Prompts are invoked as `/prompts:<name>` in the slash command UI. citeturn3view0
- Prompt front matter supports `description:` and `argument-hint:`. citeturn3view0turn2view3
- Prompt arguments support `$1``$9`, `$ARGUMENTS`, and named placeholders like `$FILE` provided as `KEY=value`. citeturn2view3
- Codex ignores non-Markdown files in the prompts directory. citeturn2view3
- Custom prompts are Markdown files stored under `~/.codex/prompts/`. citeturn3view0
- Custom prompts require explicit invocation and aren't shared through the repository; use skills to share or auto-invoke. citeturn3view0
- Prompts are invoked as `/prompts:<name>` in the slash command UI. citeturn3view0
- Prompt front matter supports `description:` and `argument-hint:`. citeturn3view0turn2view3
- Prompt arguments support `$1`-`$9`, `$ARGUMENTS`, and named placeholders like `$FILE` provided as `KEY=value`. citeturn2view3
- Codex ignores non-Markdown files in the prompts directory. citeturn2view3

## AGENTS.md instructions

- Codex reads `AGENTS.md` files before doing any work and builds a combined instruction chain. citeturn3view1
- Discovery order: global (`~/.codex`, using `AGENTS.override.md` then `AGENTS.md`) then project directory traversal from repo root to CWD, with override > AGENTS > fallback names. citeturn3view1
- Codex concatenates files from root down; files closer to the working directory appear later and override earlier guidance. citeturn3view1
- Codex reads `AGENTS.md` files before doing any work and builds a combined instruction chain. citeturn3view1
- Discovery order: global (`~/.codex`, using `AGENTS.override.md` then `AGENTS.md`) then project directory traversal from repo root to CWD, with override > AGENTS > fallback names. citeturn3view1
- Codex concatenates files from root down; files closer to the working directory appear later and override earlier guidance. citeturn3view1

## Skills (Agent Skills)

- A skill is a folder containing `SKILL.md` plus optional `scripts/`, `references/`, and `assets/`. citeturn3view3turn3view4
- `SKILL.md` uses YAML front matter and requires `name` and `description`. citeturn3view3turn3view4
- Required fields are single-line with length limits (name 100 chars, description 500 chars). citeturn3view4
- At startup, Codex loads only each skills name/description; full content is injected when invoked. citeturn3view3turn3view4
- Skills can be repo-scoped in `.agents/skills/` and are discovered from the current working directory up to the repository root. User-scoped skills live in `~/.agents/skills/`. citeturn1view1turn1view4
- A skill is a folder containing `SKILL.md` plus optional `scripts/`, `references/`, and `assets/`. citeturn3view3turn3view4
- `SKILL.md` uses YAML front matter and requires `name` and `description`. citeturn3view3turn3view4
- Required fields are single-line with length limits (name <= 100 chars, description <= 500 chars). citeturn3view4
- At startup, Codex loads only each skill's name/description; full content is injected when invoked. citeturn3view3turn3view4
- Skills can be repo-scoped in `.agents/skills/` and are discovered from the current working directory up to the repository root. User-scoped skills live in `~/.agents/skills/`. citeturn1view1turn1view4
- Inference: some existing tooling and user setups still use `.codex/skills/` and `~/.codex/skills/` as legacy compatibility paths, but those locations are not documented in the current OpenAI Codex skills docs linked above.
- Codex also supports admin-scoped skills in `/etc/codex/skills` plus built-in system skills bundled with Codex. citeturn1view4
- Skills can be invoked explicitly using `/skills` or `$skill-name`. citeturn3view3
- Codex also supports admin-scoped skills in `/etc/codex/skills` plus built-in system skills bundled with Codex. citeturn1view4
- Skills can be invoked explicitly using `/skills` or `$skill-name`. citeturn3view3

## MCP (Model Context Protocol)

- MCP configuration lives in `~/.codex/config.toml` and is shared by the CLI and IDE extension. citeturn3view2turn3view5
- Each server is configured under `[mcp_servers.<server-name>]`. citeturn3view5
- STDIO servers support `command` (required), `args`, `env`, `env_vars`, and `cwd`. citeturn3view5
- Streamable HTTP servers support `url` (required), `bearer_token_env_var`, `http_headers`, and `env_http_headers`. citeturn3view5
- MCP configuration lives in `~/.codex/config.toml` and is shared by the CLI and IDE extension. citeturn3view2turn3view5
- Each server is configured under `[mcp_servers.<server-name>]`. citeturn3view5
- STDIO servers support `command` (required), `args`, `env`, `env_vars`, and `cwd`. citeturn3view5
- Streamable HTTP servers support `url` (required), `bearer_token_env_var`, `http_headers`, and `env_http_headers`. citeturn3view5

## Hooks

Codex supports lifecycle hooks via `hooks.json`, discovered at project level (`.codex/hooks.json`), user level (`~/.codex/hooks.json`), and system level (`/etc/codex/hooks.json`). Hooks must be enabled with `codex_hooks = true` under `[features]` in `config.toml`.

### Supported events

| Event | Scope | Capabilities | Upstream PR |
|---|---|---|---|
| PreToolUse | Shell/Bash only | Deny-only decisions; tool name hardcoded to "Bash" | [#15211](https://github.com/openai/codex/pull/15211) (merged) |
| PostToolUse | Shell/Bash only | Block + additionalContext; `updatedMCPToolOutput` rejected | [#15531](https://github.com/openai/codex/pull/15531) (draft) |
| UserPromptSubmit | All prompts | Block + additionalContext; no matchers; blocked prompts never enter history | [#14626](https://github.com/openai/codex/pull/14626) (merged) |
| SessionStart | Session lifecycle | additionalContext + should_stop/stop_reason | Exists on main |
| Stop | Session lifecycle | Fires at session end | Exists on main |

### hooks.json format

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "echo before", "timeout": 30 }
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "echo prompt" }
]
}
]
}
}
```

### Converter behavior

- Only `command` type hooks are converted; `prompt` and `agent` types are skipped with warnings
- PreToolUse/PostToolUse matchers must be Bash-compatible (undefined, `*`, `""`, `Bash`, `^Bash$`); non-Bash matchers are skipped
- Mixed matchers (e.g. `Bash|Write`) are skipped entirely
- Wildcard matchers on tool-scoped events are normalized to `"Bash"`
- Non-tool-scoped events (UserPromptSubmit, SessionStart, Stop) omit the `matcher` field
- Unsupported events (PostToolUseFailure, PermissionRequest, Notification, SessionEnd, PreCompact, Setup, SubagentStart, SubagentStop) are skipped with warnings

### Constraints

- Only `type: "command"` hooks are supported (no `prompt` or `agent` types)
- Feature-gated behind `codex_hooks = true` in `config.toml` `[features]`
- `deny_unknown_fields` on all wire types -- extra fields cause parse failure
- Fail-open design: unsupported outputs, malformed JSON, non-zero/non-2 exit codes all resolve to "allow"
- No Windows support (entire hook system disabled)
- No async hooks (`async: true` is recognized but skipped with a warning)
- PreToolUse only supports deny decisions (allow/ask/updatedInput/additionalContext all fail open)
- Context injection uses developer messages (not user messages)
Loading