fix: report codex turn completion through hooks (#4756)

This commit is contained in:
JJ Liebig
2026-09-29 19:30:35 +02:00
committed by GitHub
parent f0bb7827e4
commit 4b4705dbfb
10 changed files with 169 additions and 27 deletions
@@ -59,7 +59,7 @@ Herdr saves integration configs atomically on Linux/macOS and for new files; exi
Every integration tells Herdr which session the agent is in, so Herdr can resume it after a server restart. Turn this off with `[session] resume_agents_on_restore = false`.
The Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, and MastraCode integrations also report the agent's state. While they report, Herdr uses them instead of reading the screen. The others leave state to screen detection.
The Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, and MastraCode integrations also report the agent's state. While they report, Herdr uses them instead of reading the screen. Codex reports turn state when run with `--no-daemon`; screen detection still handles its visible blockers. Other integrations leave state to screen detection.
Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`.
@@ -123,7 +123,7 @@ Install the Codex hook:
herdr integration install codex
```
The Codex hook reports session identity through the same local socket API used by other integrations. Codex state comes from Herdr's screen manifest detection.
When Codex runs with `--no-daemon`, the hook reports session identity and turn start/stop state through Herdr's local socket API. Screen detection still handles visible blockers and fills gaps when hooks are unavailable. A shared Codex daemon can route hooks to the wrong pane, so use `--no-daemon` for reliable reports; disabling daemon auto-start alone does not stop an existing daemon.
Herdr uses `~/.codex` by default, or `CODEX_HOME` when set. The Codex config directory must already exist. Install writes `herdr-agent-state.sh`, updates `hooks.json`, and ensures `[features] hooks = true` in `config.toml`. It also removes the deprecated top-level `codex_hooks` flag when present. Uninstall removes Herdr entries from `hooks.json` and deletes the hook script, but leaves `config.toml` unchanged.
+1 -2
View File
@@ -164,7 +164,6 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool {
matches!(
(source, agent),
("herdr:claude", "claude")
| ("herdr:codex", "codex")
| ("herdr:copilot", "copilot")
| ("herdr:devin", "devin")
| ("herdr:droid", "droid")
@@ -407,7 +406,7 @@ mod tests {
#[test]
fn native_state_reservation_excludes_full_lifecycle_sources() {
assert!(is_reserved_native_state_source("herdr:claude", "claude"));
assert!(is_reserved_native_state_source("herdr:codex", "codex"));
assert!(!is_reserved_native_state_source("herdr:codex", "codex"));
assert!(is_reserved_native_state_source("herdr:devin", "devin"));
assert!(!is_reserved_native_state_source("herdr:kimi", "kimi"));
assert!(!is_reserved_native_state_source(
+70
View File
@@ -3597,6 +3597,76 @@ mod tests {
(pane_id, terminal_id)
}
#[test]
fn codex_hook_turn_lifecycle_preserves_session_and_beats_stale_working_screen() {
let mut state = app_with_workspaces(&["one"]);
let (pane_id, terminal_id) = first_pane_terminal(&state);
state.handle_app_event(AppEvent::StateChanged {
pane_id,
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_working: true,
process_exited: false,
observed_at: std::time::Instant::now(),
});
for (session, seq) in [("first", 1), ("second", 4)] {
state.handle_app_event(AppEvent::AgentSessionReported {
pane_id,
source: "herdr:codex".into(),
agent_label: "codex".into(),
seq: Some(seq),
session_ref: crate::agent_resume::AgentSessionRef::id(session),
session_start_source: Some("startup".into()),
});
state.handle_app_event(AppEvent::HookStateReported {
pane_id,
source: "herdr:codex".into(),
agent_label: "codex".into(),
state: AgentState::Working,
message: None,
seq: Some(seq + 1),
session_ref: crate::agent_resume::AgentSessionRef::id(session),
});
state.handle_app_event(AppEvent::HookStateReported {
pane_id,
source: "herdr:codex".into(),
agent_label: "codex".into(),
state: AgentState::Idle,
message: None,
seq: Some(seq + 2),
session_ref: crate::agent_resume::AgentSessionRef::id(session),
});
assert_eq!(state.terminals[&terminal_id].state, AgentState::Idle);
assert!(state.terminals[&terminal_id]
.last_agent_completion_seq
.is_some());
assert!(state.terminals[&terminal_id].session_ref_is_current(
&crate::agent_resume::AgentSessionRef::id(session).unwrap()
));
}
state.handle_app_event(AppEvent::HookStateReported {
pane_id,
source: "herdr:codex".into(),
agent_label: "codex".into(),
state: AgentState::Working,
message: None,
seq: Some(7),
session_ref: crate::agent_resume::AgentSessionRef::id("second"),
});
state.handle_app_event(AppEvent::HookStateReported {
pane_id,
source: "herdr:codex".into(),
agent_label: "codex".into(),
state: AgentState::Idle,
message: None,
seq: Some(8),
session_ref: crate::agent_resume::AgentSessionRef::id("first"),
});
assert_eq!(state.terminals[&terminal_id].state, AgentState::Working);
}
#[test]
fn reported_resume_follows_the_reporting_agent_until_release() {
let mut state = app_with_workspaces(&["one"]);
@@ -2,11 +2,11 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=codex
# HERDR_INTEGRATION_VERSION=8
# HERDR_INTEGRATION_VERSION=9
param([string]$Action = "")
if ($Action -ne "session") { exit 0 }
if ($Action -notin @("session", "working", "idle")) { exit 0 }
if ($env:HERDR_ENV -ne "1") { exit 0 }
if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
@@ -17,19 +17,20 @@ try {
exit 0
}
if ($payload.hook_event_name -and $payload.hook_event_name -ne "SessionStart") { exit 0 }
$expectedEvents = @{ session = @("SessionStart"); working = @("UserPromptSubmit"); idle = @("Stop", "Interrupt") }[$Action]
if ($payload.hook_event_name -and $payload.hook_event_name -notin $expectedEvents) { exit 0 }
$sessionId = $payload.session_id
if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
if ([string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 }
if ($Action -eq "session" -and [string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 }
if (-not [string]::IsNullOrWhiteSpace($env:CODEX_THREAD_ID) -and $env:CODEX_THREAD_ID -ne $sessionId) { exit 0 }
$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$seq = [DateTimeOffset]::UtcNow.Ticks
$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH }
try {
$args = @(
"pane",
"report-agent-session",
$(if ($Action -eq "session") { "report-agent-session" } else { "report-agent" }),
$env:HERDR_PANE_ID,
"--source",
"herdr:codex",
@@ -40,6 +41,7 @@ try {
"--agent-session-id",
"$sessionId"
)
if ($Action -ne "session") { $args += @("--state", $Action) }
if ($payload.hook_event_name -eq "SessionStart" -and $payload.source -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.source)) {
$args += @("--session-start-source", "$($payload.source)")
}
@@ -3,7 +3,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=codex
# HERDR_INTEGRATION_VERSION=8
# HERDR_INTEGRATION_VERSION=9
set -eu
@@ -13,7 +13,7 @@ trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM
cat >"$hook_input_file" 2>/dev/null || true
case "$action" in
session) ;;
session|working|idle) ;;
*) exit 0 ;;
esac
@@ -49,20 +49,22 @@ if hook_input_file:
hook_input = {}
hook_event_name = str(hook_input.get("hook_event_name") or "")
if hook_event_name and hook_event_name != "SessionStart":
expected_events = {"session": ("SessionStart",), "working": ("UserPromptSubmit",), "idle": ("Stop", "Interrupt")}[action]
if hook_event_name and hook_event_name not in expected_events:
raise SystemExit(0)
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
report_seq = time.time_ns()
session_id = hook_input.get("session_id")
agent_session_id = session_id if isinstance(session_id, str) and session_id else None
transcript_path = hook_input.get("transcript_path")
if not isinstance(transcript_path, str) or not transcript_path.strip():
raise SystemExit(0)
if action == "session":
transcript_path = hook_input.get("transcript_path")
if not isinstance(transcript_path, str) or not transcript_path.strip():
raise SystemExit(0)
inherited_session_id = os.environ.get("CODEX_THREAD_ID")
if inherited_session_id and inherited_session_id != agent_session_id:
raise SystemExit(0)
session_start_source = hook_input.get("source") if hook_event_name == "SessionStart" else None
session_start_source = hook_input.get("source") if action == "session" else None
if not isinstance(session_start_source, str) or not session_start_source:
session_start_source = None
if agent_session_id:
@@ -73,11 +75,16 @@ if agent_session_id:
"seq": report_seq,
"agent_session_id": agent_session_id,
}
if session_start_source:
params["session_start_source"] = session_start_source
if action == "session":
if session_start_source:
params["session_start_source"] = session_start_source
method = "pane.report_agent_session"
else:
params["state"] = action
method = "pane.report_agent"
request = {
"id": request_id,
"method": "pane.report_agent_session",
"method": method,
"params": params,
}
else:
+1 -1
View File
@@ -60,7 +60,7 @@ const CODEX_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/codex/herdr-agent-state.sh")
};
const CODEX_INTEGRATION_VERSION: u32 = 8;
const CODEX_INTEGRATION_VERSION: u32 = 9;
const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
+14
View File
@@ -199,6 +199,19 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
10,
None,
)?;
for (event, action) in [
("UserPromptSubmit", "working"),
("Stop", "idle"),
("Interrupt", "idle"),
] {
ensure_command_hook(
hooks,
event,
hook_command(&hook_path, Some(action)),
10,
None,
)?;
}
remove_legacy_bash_hook_file(&hook_path)?;
write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
@@ -629,6 +642,7 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
updated_hooks |=
remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
updated_hooks |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
updated_hooks |= remove_hook_commands(hooks, "Interrupt", &hook_path, Some("idle"))?;
}
if updated_hooks {
+22 -6
View File
@@ -1304,7 +1304,7 @@ fn codex_v2_integration_status_is_outdated() {
assert_eq!(codex.path, hook_path);
assert_eq!(codex.installed_version, Some(2));
assert_eq!(codex.expected_version, 8);
assert_eq!(codex.expected_version, 9);
assert_eq!(codex.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -1335,10 +1335,20 @@ fn install_codex_writes_hook_and_updates_hooks_and_config() {
.as_str()
.unwrap()
.contains(" session"));
assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
assert!(hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(hooks["hooks"].get("PreToolUse").is_none());
assert!(hooks["hooks"].get("PermissionRequest").is_none());
assert!(hooks["hooks"].get("Stop").is_none());
assert!(hooks["hooks"]["Stop"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" idle"));
assert!(hooks["hooks"]["Interrupt"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" idle"));
assert!(config.contains("model = \"gpt-5.4\""));
assert!(config.contains("[features]"));
assert!(config.contains("hooks = true"));
@@ -1389,10 +1399,14 @@ fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() {
let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
assert_eq!(
hooks["hooks"]["UserPromptSubmit"].as_array().unwrap().len(),
1
);
assert!(hooks["hooks"].get("PreToolUse").is_none());
assert!(hooks["hooks"].get("PermissionRequest").is_none());
assert!(hooks["hooks"].get("Stop").is_none());
assert_eq!(hooks["hooks"]["Stop"].as_array().unwrap().len(), 1);
assert_eq!(hooks["hooks"]["Interrupt"].as_array().unwrap().len(), 1);
assert_eq!(config.matches("hooks = true").count(), 1);
assert!(!config.contains("codex_hooks"));
assert!(config.contains("other = true"));
@@ -1444,7 +1458,8 @@ fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
]}],
"PreToolUse": [{"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]}],
"PermissionRequest": [{"hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]}],
"Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}]
"Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}],
"Interrupt": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}]
}
});
fs::write(
@@ -1471,6 +1486,7 @@ fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
assert!(hooks["hooks"].get("PreToolUse").is_none());
assert!(hooks["hooks"].get("PermissionRequest").is_none());
assert!(hooks["hooks"].get("Stop").is_none());
assert!(hooks["hooks"].get("Interrupt").is_none());
assert_eq!(
hooks["hooks"]["UserPromptSubmit"][0]["hooks"]
.as_array()
+19
View File
@@ -733,6 +733,15 @@ impl TerminalState {
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
return None;
}
if (source.as_str(), agent_label.as_str()) == ("herdr:codex", "codex")
&& session_ref.as_ref().is_some_and(|incoming| {
self.current_session_identity_for_persistence().is_some_and(
|(_, _, kind, value)| kind != incoming.kind || value != incoming.value,
)
})
{
return None;
}
let owner_conflicts = self.current_session_owner_conflicts(&source, &agent_label);
let foreground_takeover_allowed = owner_conflicts
&& self.foreground_agent_confirms_hook_authority_takeover(
@@ -1651,6 +1660,16 @@ impl TerminalState {
let previous_state = self.state;
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_session = self.current_session_identity_for_persistence();
if (source.as_str(), agent_label.as_str()) == ("herdr:codex", "codex")
&& session_replacement_allowed
&& self.hook_authority.as_ref().is_some_and(|authority| {
authority.source == source
&& authority.agent_label == agent_label
&& authority.session_ref.as_ref() != Some(&session_ref)
})
{
self.hook_authority = None;
}
if session_replacement_allowed || foreground_takeover_allowed {
self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref);
}
+15
View File
@@ -222,6 +222,21 @@ fn codex_hook_reports_persisted_root_session_and_ignores_ephemeral_or_nested_ses
&[("CODEX_THREAD_ID", "parent-session")],
)
.is_none());
for (action, event) in [
("working", "UserPromptSubmit"),
("idle", "Stop"),
("idle", "Interrupt"),
] {
let request = run_codex_hook(
action,
&format!(r#"{{"hook_event_name":"{event}","session_id":"codex-session"}}"#),
)
.expect("Codex turn hook should report state");
assert_eq!(request["method"], "pane.report_agent");
assert_eq!(request["params"]["state"], action);
assert_eq!(request["params"]["agent_session_id"], "codex-session");
}
}
#[test]