diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 691bc306..96990acf 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -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. diff --git a/src/agent_resume.rs b/src/agent_resume.rs index d6a68e45..b5ee5f50 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -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( diff --git a/src/app/actions.rs b/src/app/actions.rs index e1ae2ffc..1f571f68 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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"]); diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/src/integration/assets/codex/herdr-agent-state.ps1 index 81ed381b..1a701102 100644 --- a/src/integration/assets/codex/herdr-agent-state.ps1 +++ b/src/integration/assets/codex/herdr-agent-state.ps1 @@ -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)") } diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index 2931936a..0823abd4 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -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: diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 673225a3..674f9ed2 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -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 { diff --git a/src/integration/targets.rs b/src/integration/targets.rs index ec605866..add6086e 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -199,6 +199,19 @@ pub(crate) fn install_codex() -> io::Result { 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 { 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 { diff --git a/src/integration/tests.rs b/src/integration/tests.rs index bffe2f45..c8db3790 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -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() diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 11d540b1..669a5801 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -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); } diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs index 31978a03..3021ecb2 100644 --- a/tests/cli/hooks.rs +++ b/tests/cli/hooks.rs @@ -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]