fix: drop codex turn status hooks

refs #4507
This commit is contained in:
Ogulcan Celik
2026-10-14 18:03:13 +03:00
parent f53f92dd40
commit d2fe6f98af
9 changed files with 67 additions and 160 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. Codex reports turn state when run with `--no-daemon`; screen detection still handles its visible blockers. Other integrations 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. The others 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
```
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.
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.
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.
+2 -1
View File
@@ -164,6 +164,7 @@ 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")
@@ -406,7 +407,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
@@ -3671,76 +3671,6 @@ 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"]);
@@ -6,7 +6,7 @@
param([string]$Action = "")
if ($Action -notin @("session", "working", "idle")) { exit 0 }
if ($Action -ne "session") { exit 0 }
if ($env:HERDR_ENV -ne "1") { exit 0 }
if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
@@ -20,12 +20,11 @@ try {
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 }
if ($payload.hook_event_name -and $payload.hook_event_name -ne "SessionStart") { exit 0 }
$sessionId = $payload.session_id
if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
if ($Action -eq "session" -and [string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 }
if ([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.Ticks
@@ -33,7 +32,7 @@ $herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else
try {
$args = @(
"pane",
$(if ($Action -eq "session") { "report-agent-session" } else { "report-agent" }),
"report-agent-session",
$env:HERDR_PANE_ID,
"--source",
"herdr:codex",
@@ -44,7 +43,6 @@ 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)")
}
@@ -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|working|idle) ;;
session) ;;
*) exit 0 ;;
esac
@@ -49,22 +49,20 @@ if hook_input_file:
hook_input = {}
hook_event_name = str(hook_input.get("hook_event_name") or "")
expected_events = {"session": ("SessionStart",), "working": ("UserPromptSubmit",), "idle": ("Stop", "Interrupt")}[action]
if hook_event_name and hook_event_name not in expected_events:
if hook_event_name and hook_event_name != "SessionStart":
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
if action == "session":
transcript_path = hook_input.get("transcript_path")
if not isinstance(transcript_path, str) or not transcript_path.strip():
raise SystemExit(0)
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 action == "session" else None
session_start_source = hook_input.get("source") if hook_event_name == "SessionStart" else None
if not isinstance(session_start_source, str) or not session_start_source:
session_start_source = None
if agent_session_id:
@@ -75,16 +73,11 @@ if agent_session_id:
"seq": report_seq,
"agent_session_id": agent_session_id,
}
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"
if session_start_source:
params["session_start_source"] = session_start_source
request = {
"id": request_id,
"method": method,
"method": "pane.report_agent_session",
"params": params,
}
else:
+1 -13
View File
@@ -191,6 +191,7 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?;
remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?;
remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
remove_hook_commands(hooks, "Interrupt", &hook_path, Some("idle"))?;
remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
ensure_command_hook(
hooks,
@@ -199,19 +200,6 @@ 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)?)?;
+49 -18
View File
@@ -1461,20 +1461,11 @@ fn install_codex_writes_hook_and_updates_hooks_and_config() {
.as_str()
.unwrap()
.contains(" session"));
assert!(hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
assert!(hooks["hooks"].get("PreToolUse").is_none());
assert!(hooks["hooks"].get("PermissionRequest").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!(hooks["hooks"].get("Stop").is_none());
assert!(hooks["hooks"].get("Interrupt").is_none());
assert!(config.contains("model = \"gpt-5.4\""));
assert!(config.contains("[features]"));
assert!(config.contains("hooks = true"));
@@ -1525,14 +1516,11 @@ 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_eq!(
hooks["hooks"]["UserPromptSubmit"].as_array().unwrap().len(),
1
);
assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
assert!(hooks["hooks"].get("PreToolUse").is_none());
assert!(hooks["hooks"].get("PermissionRequest").is_none());
assert_eq!(hooks["hooks"]["Stop"].as_array().unwrap().len(), 1);
assert_eq!(hooks["hooks"]["Interrupt"].as_array().unwrap().len(), 1);
assert!(hooks["hooks"].get("Stop").is_none());
assert!(hooks["hooks"].get("Interrupt").is_none());
assert_eq!(config.matches("hooks = true").count(), 1);
assert!(!config.contains("codex_hooks"));
assert!(config.contains("other = true"));
@@ -1566,6 +1554,49 @@ fn install_codex_only_migrates_top_level_feature_flags() {
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_codex_removes_turn_status_hooks_from_previous_integration() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).unwrap();
let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
let hook = |action| json!({"type": "command", "command": hook_command(&hook_path, Some(action)), "timeout": 10});
let hooks = json!({
"hooks": {
"SessionStart": [{"hooks": [hook("session")]}],
"UserPromptSubmit": [{"hooks": [
hook("working"),
{"type": "command", "command": "echo keep", "timeout": 10}
]}],
"Stop": [{"hooks": [hook("idle")]}],
"Interrupt": [{"hooks": [hook("idle")]}]
}
});
fs::write(
codex_dir.join("hooks.json"),
serde_json::to_string(&hooks).unwrap(),
)
.unwrap();
std::env::set_var("HOME", &home);
install_codex().unwrap();
let hooks: Value =
serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap()).unwrap();
assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
assert_eq!(
hooks["hooks"]["UserPromptSubmit"][0]["hooks"],
json!([{"type": "command", "command": "echo keep", "timeout": 10}])
);
assert!(hooks["hooks"].get("Stop").is_none());
assert!(hooks["hooks"].get("Interrupt").is_none());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
let _lock = integration_env_lock();
-19
View File
@@ -733,15 +733,6 @@ 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(
@@ -1660,16 +1651,6 @@ 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,21 +222,6 @@ 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]