added codex hooks
This commit is contained in:
@@ -0,0 +1 @@
|
||||
notify = ["python3", ".codex/hooks/scripts/hooks.py"]
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "^startup$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .codex/hooks/scripts/hooks.py --hook SessionStart",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Loading project context"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .codex/hooks/scripts/hooks.py --hook Stop",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Running session stop hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
# HOOKS-README
|
||||
Contains all the details, scripts, and instructions for the Codex CLI hooks.
|
||||
|
||||
## Hook Events Overview
|
||||
|
||||
Codex CLI provides **3 hooks** across two configuration systems:
|
||||
|
||||
| # | Hook | Event Type | Config File | Description |
|
||||
|:-:|------|------------|-------------|-------------|
|
||||
| 1 | `agent-turn-complete` | `agent-turn-complete` | `config.toml` | Runs when the Codex agent finishes responding |
|
||||
| 2 | `SessionStart` | `SessionStart` | `hooks.json` | Runs once at session start — injects context + plays sound |
|
||||
| 3 | `Stop` | `stop` | `hooks.json` | Runs when the session ends — plays sound |
|
||||
|
||||
> Hooks 2 and 3 require **Codex CLI v0.114.0+** with the hooks engine enabled:
|
||||
> ```bash
|
||||
> codex -c features.codex_hooks=true
|
||||
> ```
|
||||
|
||||
### How Hooks Are Called
|
||||
|
||||
**agent-turn-complete hook** (config.toml) — JSON passed as CLI argument:
|
||||
```
|
||||
python3 .codex/hooks/scripts/hooks.py '{"type":"agent-turn-complete"}'
|
||||
```
|
||||
|
||||
**SessionStart / Stop hooks** (hooks.json) — called with `--hook` flag:
|
||||
```
|
||||
python3 .codex/hooks/scripts/hooks.py --hook SessionStart
|
||||
python3 .codex/hooks/scripts/hooks.py --hook Stop
|
||||
```
|
||||
|
||||
### SessionStart Context Injection
|
||||
|
||||
The SessionStart hook outputs context to **stdout**, which feeds directly into the model's context window. This includes:
|
||||
- Current date/time
|
||||
- Git branch name
|
||||
- Working tree status (clean or uncommitted changes)
|
||||
- Working directory path
|
||||
|
||||
> **Key difference from Claude Code:** Claude Code passes JSON via **stdin**, while Codex CLI passes it as a **CLI argument**. The new hooks.json system uses `--hook` flags.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using hooks, ensure you have **Python 3** installed on your system.
|
||||
|
||||
### Required Software
|
||||
|
||||
#### All Platforms (Windows, macOS, Linux)
|
||||
- **Python 3**: Required for running the hook script
|
||||
- Verify installation: `python3 --version`
|
||||
|
||||
**Installation Instructions:**
|
||||
- **Windows**: Download from [python.org](https://www.python.org/downloads/) or install via `winget install Python.Python.3`
|
||||
- **macOS**: Install via `brew install python3` (requires [Homebrew](https://brew.sh/))
|
||||
- **Linux**: Install via `sudo apt install python3` (Ubuntu/Debian) or `sudo yum install python3` (RHEL/CentOS)
|
||||
|
||||
### Audio Players (Automatically Detected)
|
||||
|
||||
The hook script automatically detects and uses the appropriate audio player for your platform:
|
||||
|
||||
- **macOS**: Uses `afplay` (built-in, no installation needed)
|
||||
- **Linux**: Uses `paplay` from `pulseaudio-utils` - install via `sudo apt install pulseaudio-utils`
|
||||
- **Windows**: Uses built-in `winsound` module (included with Python)
|
||||
|
||||
### Configuration Files
|
||||
|
||||
There are **three** configuration files:
|
||||
|
||||
1. **`.codex/config.toml`** — Registers the `agent-turn-complete` hook (via `notify`)
|
||||
2. **`.codex/hooks.json`** — Registers `SessionStart` and `Stop` hooks (v0.114.0+)
|
||||
3. **`.codex/hooks/config/hooks-config.json`** — Enable/disable individual hooks and logging
|
||||
|
||||
#### config.toml (agent-turn-complete hook)
|
||||
|
||||
```toml
|
||||
notify = ["python3", ".codex/hooks/scripts/hooks.py"]
|
||||
```
|
||||
|
||||
#### hooks.json (SessionStart + Stop hooks)
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "python3 .codex/hooks/scripts/hooks.py --hook SessionStart",
|
||||
"statusMessage": "Initializing session hooks...",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "python3 .codex/hooks/scripts/hooks.py --hook Stop",
|
||||
"statusMessage": "Running session stop hook...",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuring Hooks (Enable/Disable)
|
||||
|
||||
### Disable Individual Hooks
|
||||
|
||||
Edit `.codex/hooks/config/hooks-config.json`:
|
||||
```json
|
||||
{
|
||||
"disableAgentTurnCompleteHook": false,
|
||||
"disableSessionStartHook": false,
|
||||
"disableStopHook": false,
|
||||
"disableLogging": true
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `disableAgentTurnCompleteHook`: Set to `true` to disable the agent-turn-complete sound
|
||||
- `disableSessionStartHook`: Set to `true` to disable the session start context injection and sound
|
||||
- `disableStopHook`: Set to `true` to disable the session stop sound
|
||||
- `disableLogging`: Set to `true` to disable logging hook events to `.codex/hooks/logs/hooks-log.jsonl`
|
||||
|
||||
### Configuration Fallback
|
||||
|
||||
There are two configuration files:
|
||||
|
||||
1. **`.codex/hooks/config/hooks-config.json`** - The shared/default configuration that is committed to git
|
||||
2. **`.codex/hooks/config/hooks-config.local.json`** - Your personal overrides (git-ignored)
|
||||
|
||||
The local config file (`.local.json`) takes precedence over the shared config, allowing each developer to customize their hook behavior without affecting the team.
|
||||
|
||||
#### Local Configuration (Personal Overrides)
|
||||
|
||||
Create or edit `.codex/hooks/config/hooks-config.local.json` for personal preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"disableAgentTurnCompleteHook": true,
|
||||
"disableSessionStartHook": false,
|
||||
"disableStopHook": true,
|
||||
"disableLogging": true
|
||||
}
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
When logging is enabled (`"disableLogging": false`), hook events are logged to `.codex/hooks/logs/hooks-log.jsonl` in JSON Lines format. Each entry contains the full JSON payload received from Codex CLI.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
python3 -m unittest tests.test_hooks -v
|
||||
```
|
||||
|
||||
## Future Extensibility
|
||||
|
||||
This project can be extended by:
|
||||
|
||||
1. Adding new entries to `HOOK_SOUND_MAP` in `hooks.py`
|
||||
2. Adding corresponding sound files in `.codex/hooks/sounds/`
|
||||
3. Adding toggle keys in `hooks-config.json`
|
||||
4. Adding new hook entries in `hooks.json`
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"disableAgentTurnCompleteHook": false,
|
||||
"disableSessionStartHook": false,
|
||||
"disableStopHook": false,
|
||||
"disableLogging": true
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Codex CLI Hook Handler
|
||||
=============================================
|
||||
This script handles hooks from Codex CLI and plays sounds.
|
||||
Codex CLI supports 3 hooks:
|
||||
1. agent-turn-complete - via config.toml (notify)
|
||||
2. SessionStart - via hooks.json (v0.114.0+)
|
||||
3. Stop - via hooks.json (v0.114.0+)
|
||||
|
||||
Input:
|
||||
- agent-turn-complete hook: JSON payload passed as CLI argument (sys.argv[1])
|
||||
- SessionStart/Stop hooks: --hook <hook-name> flag
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Windows-only module for playing WAV files
|
||||
try:
|
||||
import winsound
|
||||
except ImportError:
|
||||
winsound = None
|
||||
|
||||
# ===== HOOK EVENT TO SOUND MAPPING =====
|
||||
# Sound name -> resolves to sounds/<name>/<name>.{mp3|wav}
|
||||
HOOK_SOUND_MAP = {
|
||||
"agent-turn-complete": "agent-turn-complete",
|
||||
"SessionStart": "SessionStart",
|
||||
"Stop": "Stop",
|
||||
}
|
||||
|
||||
# ===== HOOK EVENT TO CONFIG KEY MAPPING =====
|
||||
HOOK_CONFIG_MAP = {
|
||||
"agent-turn-complete": "disableAgentTurnCompleteHook",
|
||||
"SessionStart": "disableSessionStartHook",
|
||||
"Stop": "disableStopHook",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def get_audio_player():
|
||||
"""
|
||||
Detect the appropriate audio player for the current platform.
|
||||
|
||||
Returns:
|
||||
List of command and args to use for playing audio, or None if no player found
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
# macOS: use afplay (built-in)
|
||||
return ["afplay"]
|
||||
elif system == "Linux":
|
||||
# Linux: try different players in order of preference
|
||||
players = [
|
||||
["paplay"], # PulseAudio (most common on modern Linux)
|
||||
["aplay"], # ALSA (fallback)
|
||||
["ffplay", "-nodisp", "-autoexit"], # FFmpeg (if installed)
|
||||
["mpg123", "-q"], # mpg123 (if installed)
|
||||
]
|
||||
|
||||
for player in players:
|
||||
try:
|
||||
subprocess.run(
|
||||
["which", player[0]],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True
|
||||
)
|
||||
return player
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
return None
|
||||
elif system == "Windows":
|
||||
# Windows: Use winsound for WAV files (built-in, reliable)
|
||||
return ["WINDOWS"]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def play_sound(sound_name):
|
||||
"""
|
||||
Play a sound file for the given sound name.
|
||||
|
||||
Args:
|
||||
sound_name: Name of the sound file (e.g., "agent-turn-complete")
|
||||
The file should be at .codex/hooks/sounds/{name}/{name}.{mp3|wav}
|
||||
|
||||
Returns:
|
||||
True if sound played successfully, False otherwise
|
||||
"""
|
||||
# Security check: Prevent directory traversal attacks
|
||||
if "/" in sound_name or "\\" in sound_name or ".." in sound_name:
|
||||
print(f"Invalid sound name: {sound_name}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
audio_player = get_audio_player()
|
||||
if not audio_player:
|
||||
return False
|
||||
|
||||
# Build path: scripts/ -> hooks/ -> sounds/{folder}/
|
||||
script_dir = Path(__file__).parent # .codex/hooks/scripts/
|
||||
hooks_dir = script_dir.parent # .codex/hooks/
|
||||
|
||||
# Sound folder matches sound name: sounds/<name>/<name>.{mp3|wav}
|
||||
sounds_dir = hooks_dir / "sounds" / sound_name
|
||||
|
||||
is_windows = audio_player[0] == "WINDOWS"
|
||||
extensions = ['.wav'] if is_windows else ['.wav', '.mp3']
|
||||
|
||||
for extension in extensions:
|
||||
file_path = sounds_dir / f"{sound_name}{extension}"
|
||||
|
||||
if file_path.exists():
|
||||
try:
|
||||
if is_windows:
|
||||
if winsound:
|
||||
winsound.PlaySound(str(file_path),
|
||||
winsound.SND_FILENAME | winsound.SND_NODEFAULT)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
subprocess.Popen(
|
||||
audio_player + [str(file_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
return True
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
print(f"Error playing sound {file_path.name}: {e}", file=sys.stderr)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error playing sound {file_path.name}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def load_config():
|
||||
"""
|
||||
Load the hook configuration from config files.
|
||||
Uses fallback logic: hooks-config.local.json -> hooks-config.json
|
||||
|
||||
Returns:
|
||||
Tuple of (local_config, default_config) - either may be None
|
||||
"""
|
||||
try:
|
||||
script_dir = Path(__file__).parent
|
||||
hooks_dir = script_dir.parent
|
||||
config_dir = hooks_dir / "config"
|
||||
|
||||
local_config_path = config_dir / "hooks-config.local.json"
|
||||
default_config_path = config_dir / "hooks-config.json"
|
||||
|
||||
local_config = None
|
||||
if local_config_path.exists():
|
||||
try:
|
||||
with open(local_config_path, "r", encoding="utf-8") as f:
|
||||
local_config = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
default_config = None
|
||||
if default_config_path.exists():
|
||||
try:
|
||||
with open(default_config_path, "r", encoding="utf-8") as f:
|
||||
default_config = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return local_config, default_config
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def get_config_value(key, default=False):
|
||||
"""
|
||||
Get a config value with fallback logic: local -> default -> provided default.
|
||||
|
||||
Args:
|
||||
key: The config key to look up
|
||||
default: Default value if key not found in any config
|
||||
|
||||
Returns:
|
||||
The config value
|
||||
"""
|
||||
local_config, default_config = load_config()
|
||||
|
||||
if local_config is not None and key in local_config:
|
||||
return local_config[key]
|
||||
elif default_config is not None and key in default_config:
|
||||
return default_config[key]
|
||||
else:
|
||||
return default
|
||||
|
||||
|
||||
def is_hook_disabled(event_name):
|
||||
"""
|
||||
Check if a hook is disabled in the config files.
|
||||
Uses fallback logic: hooks-config.local.json -> hooks-config.json
|
||||
|
||||
Args:
|
||||
event_name: The event name (e.g., "agent-turn-complete", "SessionStart", "Stop")
|
||||
|
||||
Returns:
|
||||
True if the hook is disabled, False otherwise
|
||||
"""
|
||||
config_key = HOOK_CONFIG_MAP.get(event_name, "disableAgentTurnCompleteHook")
|
||||
return get_config_value(config_key, default=False)
|
||||
|
||||
|
||||
def is_logging_disabled():
|
||||
"""
|
||||
Check if logging is disabled in the config files.
|
||||
Uses fallback logic: hooks-config.local.json -> hooks-config.json
|
||||
|
||||
Returns:
|
||||
True if logging is disabled, False otherwise
|
||||
"""
|
||||
return get_config_value("disableLogging", default=False)
|
||||
|
||||
|
||||
def log_hook_data(hook_data):
|
||||
"""
|
||||
Log the hook_data to hooks-log.jsonl for debugging/auditing.
|
||||
Log file is stored at .codex/hooks/logs/hooks-log.jsonl
|
||||
|
||||
Logs 3 keys: hook, timestamp, last_assistant_message
|
||||
"""
|
||||
if is_logging_disabled():
|
||||
return
|
||||
|
||||
try:
|
||||
log_entry = {
|
||||
"hook": hook_data.get("type", ""),
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"last_assistant_message": hook_data.get("last_assistant_message", ""),
|
||||
}
|
||||
|
||||
script_dir = Path(__file__).parent
|
||||
hooks_dir = script_dir.parent
|
||||
logs_dir = hooks_dir / "logs"
|
||||
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_path = logs_dir / "hooks-log.jsonl"
|
||||
with open(log_path, "a", encoding="utf-8") as log_file:
|
||||
log_file.write(json.dumps(log_entry, ensure_ascii=False, indent=2) + "\n")
|
||||
except Exception as e:
|
||||
print(f"Failed to log hook_data: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def get_session_context():
|
||||
"""
|
||||
Gather context information for SessionStart hook.
|
||||
This output goes to stdout and feeds into the model's context.
|
||||
|
||||
Returns:
|
||||
String of context information
|
||||
"""
|
||||
return "hooks context: run"
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
"""
|
||||
Parse command line arguments.
|
||||
Supports two calling conventions:
|
||||
1. agent-turn-complete hook (config.toml): hooks.py '{"type":"agent-turn-complete"}'
|
||||
2. SessionStart/Stop hooks (hooks.json): hooks.py --hook SessionStart
|
||||
|
||||
Args:
|
||||
argv: sys.argv[1:] list
|
||||
|
||||
Returns:
|
||||
Tuple of (event_type, input_data) where input_data is the parsed JSON dict or None
|
||||
"""
|
||||
if not argv:
|
||||
return None, None
|
||||
|
||||
# New hooks.json calling convention: --hook <event-type>
|
||||
# The hooks engine passes JSON via stdin
|
||||
if argv[0] == "--hook" and len(argv) >= 2:
|
||||
event_type = argv[1]
|
||||
input_data = {"type": event_type}
|
||||
# Read stdin payload from hooks engine (non-blocking)
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
stdin_data = sys.stdin.read()
|
||||
if stdin_data.strip():
|
||||
input_data = json.loads(stdin_data)
|
||||
input_data["type"] = event_type
|
||||
except Exception:
|
||||
pass
|
||||
return event_type, input_data
|
||||
|
||||
# agent-turn-complete hook: JSON as CLI argument
|
||||
try:
|
||||
input_data = json.loads(argv[0])
|
||||
event_type = input_data.get("type", "")
|
||||
return event_type, input_data
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing JSON input: {e}", file=sys.stderr)
|
||||
return None, None
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main program - runs when Codex CLI triggers a hook.
|
||||
|
||||
Supports 3 hooks:
|
||||
1. agent-turn-complete (config.toml): Plays sound on agent-turn-complete
|
||||
2. SessionStart (hooks.json): Outputs context to stdout + plays sound
|
||||
3. Stop (hooks.json): Plays sound on session end
|
||||
"""
|
||||
try:
|
||||
event_type, input_data = parse_args(sys.argv[1:])
|
||||
|
||||
if not event_type:
|
||||
sys.exit(0)
|
||||
|
||||
# Log hook data
|
||||
if input_data:
|
||||
log_hook_data(input_data)
|
||||
|
||||
# Check if the hook is disabled
|
||||
if is_hook_disabled(event_type):
|
||||
sys.exit(0)
|
||||
|
||||
# SessionStart: Output context to stdout (feeds into model context)
|
||||
if event_type == "SessionStart":
|
||||
context = get_session_context()
|
||||
if context:
|
||||
print(context)
|
||||
|
||||
# Determine which sound to play
|
||||
sound_name = HOOK_SOUND_MAP.get(event_type)
|
||||
|
||||
# Play the sound
|
||||
if sound_name:
|
||||
play_sound(sound_name)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user