From 295b09ca25edbccac65401ee84fa420dd3888aad Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 2 Jun 2026 21:20:28 +0300 Subject: feat: make claude codex opencode hooks session-only --- src/integration/assets/claude/herdr-agent-state.sh | 27 +-- src/integration/assets/codex/herdr-agent-state.sh | 23 +-- .../assets/opencode/herdr-agent-state.js | 74 ++----- src/integration/mod.rs | 223 ++++++++------------- 4 files changed, 112 insertions(+), 235 deletions(-) (limited to 'src/integration') diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index e2a970d3..12068a69 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/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=claude -# HERDR_INTEGRATION_VERSION=4 +# HERDR_INTEGRATION_VERSION=5 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 - working|idle|blocked|release) ;; + session) ;; *) exit 0 ;; esac @@ -55,39 +55,24 @@ if hook_event_name == "SubagentStop": # to durable working, but Claude recap/away-summary can emit it after the # main turn has already stopped. Never let it revive an idle pane. raise SystemExit(0) -if is_subagent and action in ("idle", "release"): - # Subagent completion must not make the parent pane look done early. - 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 == "release": +if agent_session_id: request = { "id": request_id, - "method": "pane.release_agent", + "method": "pane.report_agent_session", "params": { "pane_id": pane_id, "source": source, "agent": "claude", "seq": report_seq, + "agent_session_id": agent_session_id, }, } else: - request = { - "id": request_id, - "method": "pane.report_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "claude", - "state": action, - "seq": report_seq, - }, - } - if agent_session_id: - request["params"]["agent_session_id"] = agent_session_id + raise SystemExit(0) try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index 5341d71c..385c9a98 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=4 +# HERDR_INTEGRATION_VERSION=5 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 - working|idle|blocked|release) ;; + session) ;; *) exit 0 ;; esac @@ -52,31 +52,20 @@ request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):0 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 == "release": +if agent_session_id: request = { "id": request_id, - "method": "pane.release_agent", + "method": "pane.report_agent_session", "params": { "pane_id": pane_id, "source": source, "agent": "codex", "seq": report_seq, + "agent_session_id": agent_session_id, }, } else: - request = { - "id": request_id, - "method": "pane.report_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "codex", - "state": action, - "seq": report_seq, - }, - } - if agent_session_id: - request["params"]["agent_session_id"] = agent_session_id + raise SystemExit(0) try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index ed0958fd..c2fd79a6 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=opencode -// HERDR_INTEGRATION_VERSION=3 +// HERDR_INTEGRATION_VERSION=4 import net from "node:net"; @@ -20,7 +20,10 @@ function sessionIDFromProperties(properties) { : undefined; } -function reportState(action, sessionID) { +function reportSession(sessionID) { + if (!sessionID) { + return Promise.resolve(); + } const paneId = process.env.HERDR_PANE_ID; const socketPath = process.env.HERDR_SOCKET_PATH; @@ -31,26 +34,16 @@ function reportState(action, sessionID) { const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000) .toString() .padStart(6, "0")}`; - const params = - action === "release" - ? { - pane_id: paneId, - source: SOURCE, - agent: "opencode", - seq: nextReportSeq(), - } - : { - pane_id: paneId, - source: SOURCE, - agent: "opencode", - state: action, - seq: nextReportSeq(), - ...(sessionID ? { agent_session_id: sessionID } : {}), - }; const request = { id: requestId, - method: action === "release" ? "pane.release_agent" : "pane.report_agent", - params, + method: "pane.report_agent_session", + params: { + pane_id: paneId, + source: SOURCE, + agent: "opencode", + seq: nextReportSeq(), + agent_session_id: sessionID, + }, }; return new Promise((resolve) => { @@ -81,53 +74,16 @@ export const HerdrAgentStatePlugin = async () => { } return { - dispose: async () => { - await reportState("release"); - }, event: async ({ event }) => { const type = event?.type; const properties = event?.properties ?? {}; const sessionID = sessionIDFromProperties(properties); switch (type) { - case "permission.asked": - case "question.asked": - await reportState("blocked", sessionID); - break; - case "permission.replied": { - const reply = properties.reply ?? properties.response; - if (reply === "reject") { - await reportState("idle", sessionID); - } else if (reply === "once" || reply === "always") { - await reportState("working", sessionID); - } - break; - } - case "question.replied": - await reportState("working", sessionID); - break; - case "question.rejected": - await reportState("idle", sessionID); - break; case "session.created": case "session.updated": - // session.created and session.updated are metadata events; lifecycle - // state comes from session.status and the deprecated session.idle. - break; - case "session.status": { - const status = - typeof properties.status === "string" - ? properties.status - : properties.status?.type; - if (status === "busy" || status === "retry") { - await reportState("working", sessionID); - } else if (status === "idle") { - await reportState("idle", sessionID); - } - break; - } - case "session.idle": - await reportState("idle", sessionID); + case "session.status": + await reportSession(sessionID); break; default: break; diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 82afb195..31197318 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -19,11 +19,11 @@ const OMP_INTEGRATION_VERSION: u32 = 2; const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR"; const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); -const CLAUDE_INTEGRATION_VERSION: u32 = 4; +const CLAUDE_INTEGRATION_VERSION: u32 = 5; const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR"; const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); -const CODEX_INTEGRATION_VERSION: u32 = 4; +const CODEX_INTEGRATION_VERSION: u32 = 5; const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; const COPILOT_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const COPILOT_HOOK_ASSET: &str = include_str!("assets/copilot/herdr-agent-state.sh"); @@ -31,7 +31,7 @@ const COPILOT_INTEGRATION_VERSION: u32 = 1; const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); -const OPENCODE_INTEGRATION_VERSION: u32 = 3; +const OPENCODE_INTEGRATION_VERSION: u32 = 4; const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state"; const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; @@ -813,45 +813,36 @@ pub(crate) fn install_claude() -> io::Result { "SubagentStop", &format!("bash {quoted_hook_path} working"), )?; - ensure_command_hook( + remove_command_hook( + hooks, + "PermissionRequest", + &format!("bash {quoted_hook_path} blocked"), + )?; + remove_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path} idle"), - 10, - Some("*"), + &format!("bash {quoted_hook_path} idle"), )?; - ensure_command_hook( + remove_command_hook( hooks, "UserPromptSubmit", - format!("bash {quoted_hook_path} working"), - 10, - Some("*"), + &format!("bash {quoted_hook_path} working"), )?; - ensure_command_hook( + remove_command_hook( hooks, "PreToolUse", - format!("bash {quoted_hook_path} working"), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "PermissionRequest", - format!("bash {quoted_hook_path} blocked"), - 10, - Some("*"), + &format!("bash {quoted_hook_path} working"), )?; - ensure_command_hook( + remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; + remove_command_hook( hooks, - "Stop", - format!("bash {quoted_hook_path} idle"), - 10, - Some("*"), + "SessionEnd", + &format!("bash {quoted_hook_path} release"), )?; ensure_command_hook( hooks, - "SessionEnd", - format!("bash {quoted_hook_path} release"), + "SessionStart", + format!("bash {quoted_hook_path} session"), 10, Some("*"), )?; @@ -893,38 +884,31 @@ pub(crate) fn install_codex() -> io::Result { "codex hooks file hooks", )?; let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - ensure_command_hook( + remove_command_hook( + hooks, + "PermissionRequest", + &format!("bash {quoted_hook_path} blocked"), + )?; + remove_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path} idle"), - 10, - None, + &format!("bash {quoted_hook_path} idle"), )?; - ensure_command_hook( + remove_command_hook( hooks, "UserPromptSubmit", - format!("bash {quoted_hook_path} working"), - 10, - None, + &format!("bash {quoted_hook_path} working"), )?; - ensure_command_hook( + remove_command_hook( hooks, "PreToolUse", - format!("bash {quoted_hook_path} working"), - 10, - None, - )?; - ensure_command_hook( - hooks, - "PermissionRequest", - format!("bash {quoted_hook_path} blocked"), - 10, - None, + &format!("bash {quoted_hook_path} working"), )?; + remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; ensure_command_hook( hooks, - "Stop", - format!("bash {quoted_hook_path} idle"), + "SessionStart", + format!("bash {quoted_hook_path} session"), 10, None, )?; @@ -1112,6 +1096,11 @@ pub(crate) fn uninstall_claude() -> io::Result { "SessionStart", &format!("bash {quoted_hook_path} idle"), )?; + updated_settings |= remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} session"), + )?; updated_settings |= remove_command_hook( hooks, "UserPromptSubmit", @@ -1191,6 +1180,11 @@ pub(crate) fn uninstall_codex() -> io::Result { "SessionStart", &format!("bash {quoted_hook_path} idle"), )?; + updated_hooks |= remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} session"), + )?; updated_hooks |= remove_command_hook( hooks, "UserPromptSubmit", @@ -2408,35 +2402,15 @@ mod tests { assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] .as_str() .unwrap() - .contains(" idle")); - assert_eq!(settings["hooks"]["UserPromptSubmit"][0]["matcher"], "*"); - assert!( - settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" working") - ); - assert!(settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" working")); - assert!( - settings["hooks"]["PermissionRequest"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" blocked") - ); + .contains(" session")); + assert!(settings["hooks"].get("UserPromptSubmit").is_none()); + assert!(settings["hooks"].get("PreToolUse").is_none()); + assert!(settings["hooks"].get("PermissionRequest").is_none()); assert!(settings["hooks"].get("PostToolUse").is_none()); assert!(settings["hooks"].get("PostToolUseFailure").is_none()); assert!(settings["hooks"].get("SubagentStop").is_none()); - assert!(settings["hooks"]["Stop"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" idle")); - assert!(settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" release")); + assert!(settings["hooks"].get("Stop").is_none()); + assert!(settings["hooks"].get("SessionEnd").is_none()); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -2477,30 +2451,18 @@ mod tests { let settings: Value = serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap()) .unwrap(); - assert_eq!( - settings["hooks"]["UserPromptSubmit"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); - assert_eq!( - settings["hooks"]["PermissionRequest"] - .as_array() - .unwrap() - .len(), - 1 - ); assert_eq!( settings["hooks"]["SessionStart"].as_array().unwrap().len(), 1 ); + assert!(settings["hooks"].get("UserPromptSubmit").is_none()); + assert!(settings["hooks"].get("PreToolUse").is_none()); + assert!(settings["hooks"].get("PermissionRequest").is_none()); assert!(settings["hooks"].get("PostToolUse").is_none()); assert!(settings["hooks"].get("PostToolUseFailure").is_none()); assert!(settings["hooks"].get("SubagentStop").is_none()); - assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1); - assert_eq!(settings["hooks"]["SessionEnd"].as_array().unwrap().len(), 1); + assert!(settings["hooks"].get("Stop").is_none()); + assert!(settings["hooks"].get("SessionEnd").is_none()); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -2518,7 +2480,8 @@ mod tests { fs::write( claude_dir.join("settings.json"), format!( - r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-subagent","timeout":10}}]}}]}}}}"#, + r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-subagent","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}},{{"type":"command","command":"echo keep-session-end","timeout":10}}]}}]}}}}"#, + hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), @@ -2545,14 +2508,12 @@ mod tests { "echo keep-subagent" ); assert_eq!( - settings["hooks"]["UserPromptSubmit"] - .as_array() - .unwrap() - .len(), - 1 + settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"], + "echo keep-session-end" ); - assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); - assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1); + assert!(settings["hooks"].get("UserPromptSubmit").is_none()); + assert!(settings["hooks"].get("PreToolUse").is_none()); + assert!(settings["hooks"].get("Stop").is_none()); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -2581,7 +2542,7 @@ mod tests { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(1)); - assert_eq!(claude.expected_version, 4); + assert_eq!(claude.expected_version, 5); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2611,7 +2572,7 @@ mod tests { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 4); + assert_eq!(claude.expected_version, 5); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2715,7 +2676,7 @@ mod tests { assert_eq!(codex.path, hook_path); assert_eq!(codex.installed_version, Some(2)); - assert_eq!(codex.expected_version, 4); + assert_eq!(codex.expected_version, 5); assert_eq!(codex.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2745,25 +2706,11 @@ mod tests { assert!(hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] .as_str() .unwrap() - .contains(" idle")); - assert!(hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" working")); - assert!(hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" working")); - assert!( - hooks["hooks"]["PermissionRequest"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" blocked") - ); - assert!(hooks["hooks"]["Stop"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(" idle")); + .contains(" session")); + assert!(hooks["hooks"].get("UserPromptSubmit").is_none()); + assert!(hooks["hooks"].get("PreToolUse").is_none()); + assert!(hooks["hooks"].get("PermissionRequest").is_none()); + assert!(hooks["hooks"].get("Stop").is_none()); assert!(config.contains("model = \"gpt-5.4\"")); assert!(config.contains("[features]")); assert!(config.contains("hooks = true")); @@ -2815,19 +2762,10 @@ mod tests { 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_eq!(hooks["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); - assert_eq!( - hooks["hooks"]["PermissionRequest"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!(hooks["hooks"]["Stop"].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!(hooks["hooks"].get("Stop").is_none()); assert_eq!(config.matches("hooks = true").count(), 1); assert!(!config.contains("codex_hooks")); assert!(config.contains("other = true")); @@ -3297,18 +3235,27 @@ mod tests { assert!(PI_EXTENSION_ASSET.contains("agent_session_id: currentAgentSessionId")); assert!(PI_EXTENSION_ASSET.contains("publishState(true)")); assert!(CLAUDE_HOOK_ASSET.contains("agent_session_id")); + assert!(CLAUDE_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!CLAUDE_HOOK_ASSET.contains("\"state\": action")); + assert!(!CLAUDE_HOOK_ASSET.contains("pane.release_agent")); assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")); assert!(CODEX_HOOK_ASSET.contains("agent_session_id")); + assert!(CODEX_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!CODEX_HOOK_ASSET.contains("\"state\": action")); + assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent")); assert!(COPILOT_HOOK_ASSET.contains("agent_session_id")); assert!(COPILOT_HOOK_ASSET.contains("notification_type")); assert!(COPILOT_HOOK_ASSET.contains("ask_user")); assert!(COPILOT_HOOK_ASSET.contains("exit_plan_mode")); assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID")); - assert!(OPENCODE_PLUGIN_ASSET.contains("dispose: async")); assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID")); - assert!(!OPENCODE_PLUGIN_ASSET.contains("await reportState(\"idle\", sessionID);\n }\n break;\n case \"session.status\"")); + assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session")); + assert!(!OPENCODE_PLUGIN_ASSET.contains("reportState")); + assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); + assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\",")); + assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent")); // Qoder hook reads the event from the stdin JSON payload (per // https://docs.qoder.com/zh/cli/hooks). Make sure the bundled script // never reaches for a QODER_HOOK_EVENT environment variable. -- cgit v1.2.3