use std::io::Read;
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
struct CodexHookPayload {
session_id: String,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
triggered_at: Option<String>,
hook_event: Option<CodexHookEvent>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct CodexHookEvent {
event_type: String,
#[serde(default)]
thread_id: Option<String>,
#[serde(default)]
turn_id: Option<String>,
#[serde(default)]
last_assistant_message: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
tool_kind: Option<String>,
#[serde(default)]
tool_input: Option<Value>,
#[serde(default)]
executed: Option<bool>,
#[serde(default)]
success: Option<bool>,
#[serde(default)]
duration_ms: Option<u64>,
}
fn translate_codex_payload(raw: &Value) -> Result<Value> {
if raw.get("hook_event_name").is_some() {
return Ok(raw.clone());
}
let payload: CodexHookPayload =
serde_json::from_value(raw.clone()).context("Failed to parse Codex HookPayload")?;
let hook_event = payload
.hook_event
.context("Missing hook_event field in Codex payload")?;
let hook_event_name = match hook_event.event_type.as_str() {
"after_agent" => "Stop",
"after_tool_use" => "PostToolUse",
other => {
eprintln!("Unknown Codex event_type: {}, passing through", other);
other
}
};
let mut translated = json!({
"hook_event_name": hook_event_name,
"session_id": payload.session_id,
});
if let Some(cwd) = &payload.cwd {
translated["cwd"] = json!(cwd);
}
match hook_event.event_type.as_str() {
"after_agent" => {
if let Some(msg) = &hook_event.last_assistant_message {
translated["last_assistant_message"] = json!(msg);
}
translated["stop_hook_active"] = json!(false);
}
"after_tool_use" => {
if let Some(name) = &hook_event.tool_name {
translated["tool_name"] = json!(name);
}
if let Some(input) = &hook_event.tool_input {
translated["tool_input"] = input.clone();
}
}
_ => {}
}
if let Some(triggered_at) = &payload.triggered_at {
translated["codex_triggered_at"] = json!(triggered_at);
}
if let Some(kind) = &hook_event.tool_kind {
translated["codex_tool_kind"] = json!(kind);
}
Ok(translated)
}
pub fn run(port: u16, token: &str) -> Result<()> {
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.context("Failed to read stdin")?;
let input = input.trim();
if input.is_empty() {
anyhow::bail!("No input received on stdin");
}
let raw: Value = serde_json::from_str(input).context("Failed to parse input JSON")?;
let payload = translate_codex_payload(&raw)?;
let url = format!("http://localhost:{}/hooks/event", port);
let body_str = payload.to_string();
let mut req = ureq::post(&url)
.header("Authorization", &format!("Bearer {}", token))
.header("Content-Type", "application/json");
if let Ok(pane_id) = std::env::var("TMUX_PANE") {
req = req.header("X-Tmai-Pane-Id", &pane_id);
}
let response = req.send(body_str.as_bytes());
match response {
Ok(resp) => {
let body = resp
.into_body()
.read_to_string()
.context("Failed to read response body")?;
if !body.is_empty() {
print!("{}", body);
}
}
Err(e) => {
eprintln!("tmai codex-hook: failed to reach tmai server: {}", e);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_translate_new_engine_session_start() {
let raw = json!({
"hook_event_name": "SessionStart",
"session_id": "codex-sess-1",
"cwd": "/home/user/project",
"model": "o4-mini",
"permission_mode": "default",
"source": "startup"
});
let result = translate_codex_payload(&raw).unwrap();
assert_eq!(result["hook_event_name"], "SessionStart");
assert_eq!(result["session_id"], "codex-sess-1");
assert_eq!(result["model"], "o4-mini");
}
#[test]
fn test_translate_new_engine_stop() {
let raw = json!({
"hook_event_name": "Stop",
"session_id": "codex-sess-1",
"cwd": "/home/user/project",
"stop_hook_active": false,
"last_assistant_message": "Done with the task."
});
let result = translate_codex_payload(&raw).unwrap();
assert_eq!(result["hook_event_name"], "Stop");
assert_eq!(result["last_assistant_message"], "Done with the task.");
}
#[test]
fn test_translate_after_agent() {
let raw = json!({
"session_id": "codex-sess-2",
"cwd": "/tmp/project",
"triggered_at": "2026-03-11T10:00:00Z",
"hook_event": {
"event_type": "after_agent",
"thread_id": "thread-abc",
"turn_id": "turn-1",
"last_assistant_message": "Task completed successfully."
}
});
let result = translate_codex_payload(&raw).unwrap();
assert_eq!(result["hook_event_name"], "Stop");
assert_eq!(result["session_id"], "codex-sess-2");
assert_eq!(result["cwd"], "/tmp/project");
assert_eq!(
result["last_assistant_message"],
"Task completed successfully."
);
assert_eq!(result["stop_hook_active"], false);
assert_eq!(result["codex_triggered_at"], "2026-03-11T10:00:00Z");
}
#[test]
fn test_translate_after_tool_use() {
let raw = json!({
"session_id": "codex-sess-3",
"cwd": "/tmp",
"hook_event": {
"event_type": "after_tool_use",
"turn_id": "turn-5",
"tool_name": "shell",
"tool_kind": "local_shell",
"tool_input": {"command": "cargo test"},
"executed": true,
"success": true,
"duration_ms": 1500
}
});
let result = translate_codex_payload(&raw).unwrap();
assert_eq!(result["hook_event_name"], "PostToolUse");
assert_eq!(result["session_id"], "codex-sess-3");
assert_eq!(result["tool_name"], "shell");
assert_eq!(result["tool_input"]["command"], "cargo test");
assert_eq!(result["codex_tool_kind"], "local_shell");
}
#[test]
fn test_translate_missing_hook_event() {
let raw = json!({
"session_id": "codex-sess-4",
"cwd": "/tmp"
});
let result = translate_codex_payload(&raw);
assert!(result.is_err());
}
#[test]
fn test_translate_unknown_event_type() {
let raw = json!({
"session_id": "codex-sess-5",
"hook_event": {
"event_type": "future_event",
"turn_id": "turn-1"
}
});
let result = translate_codex_payload(&raw).unwrap();
assert_eq!(result["hook_event_name"], "future_event");
}
}