diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 96990acf0..691bc306c 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. 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. diff --git a/src/agent_resume.rs b/src/agent_resume.rs index b5ee5f507..d6a68e451 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -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( diff --git a/src/app/actions.rs b/src/app/actions.rs index ed131dcd3..f01dd05b9 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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"]); diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/src/integration/assets/codex/herdr-agent-state.ps1 index f3f72b9f7..1ee0db046 100644 --- a/src/integration/assets/codex/herdr-agent-state.ps1 +++ b/src/integration/assets/codex/herdr-agent-state.ps1 @@ -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)") } diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index d44b4ffd0..4e94757e9 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -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: diff --git a/src/integration/targets.rs b/src/integration/targets.rs index add6086e8..e38ee4467 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -191,6 +191,7 @@ pub(crate) fn install_codex() -> io::Result { 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 { 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)?)?; diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 556a6fbbb..147daff83 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -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(); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 669a58019..11d540b12 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -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); } diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs index 3021ecb2d..31978a031 100644 --- a/tests/cli/hooks.rs +++ b/tests/cli/hooks.rs @@ -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]