diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..5c179d1 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1 @@ +notify = ["python3", ".codex/hooks/scripts/hooks.py"] \ No newline at end of file diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..054db9c --- /dev/null +++ b/.codex/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/.codex/hooks/HOOKS-README.md b/.codex/hooks/HOOKS-README.md new file mode 100644 index 0000000..cb08009 --- /dev/null +++ b/.codex/hooks/HOOKS-README.md @@ -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` diff --git a/.codex/hooks/config/hooks-config.json b/.codex/hooks/config/hooks-config.json new file mode 100644 index 0000000..8d95d73 --- /dev/null +++ b/.codex/hooks/config/hooks-config.json @@ -0,0 +1,6 @@ +{ + "disableAgentTurnCompleteHook": false, + "disableSessionStartHook": false, + "disableStopHook": false, + "disableLogging": true +} diff --git a/.codex/hooks/logs/.gitkeep b/.codex/hooks/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.codex/hooks/scripts/hooks.py b/.codex/hooks/scripts/hooks.py new file mode 100644 index 0000000..21bb050 --- /dev/null +++ b/.codex/hooks/scripts/hooks.py @@ -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 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//.{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//.{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 + # 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() diff --git a/.codex/hooks/sounds/SessionStart/SessionStart.mp3 b/.codex/hooks/sounds/SessionStart/SessionStart.mp3 new file mode 100644 index 0000000..9a70a16 Binary files /dev/null and b/.codex/hooks/sounds/SessionStart/SessionStart.mp3 differ diff --git a/.codex/hooks/sounds/SessionStart/SessionStart.wav b/.codex/hooks/sounds/SessionStart/SessionStart.wav new file mode 100644 index 0000000..4da5a4c Binary files /dev/null and b/.codex/hooks/sounds/SessionStart/SessionStart.wav differ diff --git a/.codex/hooks/sounds/Stop/Stop.mp3 b/.codex/hooks/sounds/Stop/Stop.mp3 new file mode 100644 index 0000000..1c51dd1 Binary files /dev/null and b/.codex/hooks/sounds/Stop/Stop.mp3 differ diff --git a/.codex/hooks/sounds/Stop/Stop.wav b/.codex/hooks/sounds/Stop/Stop.wav new file mode 100644 index 0000000..7725b16 Binary files /dev/null and b/.codex/hooks/sounds/Stop/Stop.wav differ diff --git a/.codex/hooks/sounds/agent-turn-complete/agent-turn-complete.mp3 b/.codex/hooks/sounds/agent-turn-complete/agent-turn-complete.mp3 new file mode 100644 index 0000000..25ae3cf Binary files /dev/null and b/.codex/hooks/sounds/agent-turn-complete/agent-turn-complete.mp3 differ diff --git a/.codex/hooks/sounds/agent-turn-complete/agent-turn-complete.wav b/.codex/hooks/sounds/agent-turn-complete/agent-turn-complete.wav new file mode 100644 index 0000000..9bf729c Binary files /dev/null and b/.codex/hooks/sounds/agent-turn-complete/agent-turn-complete.wav differ