mirror of
https://github.com/herdrdev/herdr.git
synced 2026-10-07 16:00:41 +00:00
fix: report codex turn completion through hooks (#4756)
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user