diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 15:59:34 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 15:59:34 +0300 |
| commit | df7880c54b8b5d337421cb4b1dd8daea27008801 (patch) | |
| tree | 4d0cc6290ad71c413bac71cfaab4ef41ea017ecb /src | |
| parent | eeacb45b4b6ab4446c5bea1331f743fee5e5b9f8 (diff) | |
feat: update agent lifecycle integrations
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_detection_policy.rs | 27 | ||||
| -rw-r--r-- | src/agent_resume.rs | 82 | ||||
| -rw-r--r-- | src/api/schema.rs | 1 | ||||
| -rw-r--r-- | src/cli/integration.rs | 9 | ||||
| -rw-r--r-- | src/integration/assets/copilot/herdr-agent-state.ps1 | 84 | ||||
| -rw-r--r-- | src/integration/assets/copilot/herdr-agent-state.sh | 150 | ||||
| -rw-r--r-- | src/integration/assets/droid/herdr-agent-state.ps1 | 14 | ||||
| -rw-r--r-- | src/integration/assets/droid/herdr-agent-state.sh | 21 | ||||
| -rw-r--r-- | src/integration/assets/kilo/herdr-agent-state.js | 159 | ||||
| -rw-r--r-- | src/integration/assets/kimi/herdr-agent-state.ps1 | 20 | ||||
| -rw-r--r-- | src/integration/assets/kimi/herdr-agent-state.sh | 60 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 82 | ||||
| -rw-r--r-- | src/integration/assets/qodercli/herdr-agent-state.ps1 | 17 | ||||
| -rw-r--r-- | src/integration/assets/qodercli/herdr-agent-state.sh | 64 | ||||
| -rw-r--r-- | src/integration/mod.rs | 580 | ||||
| -rw-r--r-- | src/terminal/state.rs | 34 |
16 files changed, 844 insertions, 560 deletions
diff --git a/src/agent_detection_policy.rs b/src/agent_detection_policy.rs index a0d7ba4c..7a2677a2 100644 --- a/src/agent_detection_policy.rs +++ b/src/agent_detection_policy.rs @@ -43,7 +43,7 @@ fn screen_blocked_or_idle_fallback(detection: AgentDetection) -> AgentDetection pub(crate) fn full_lifecycle_detected_agent(agent: Agent) -> bool { matches!( agent, - Agent::Pi | Agent::GithubCopilot | Agent::Kimi | Agent::Hermes | Agent::Qodercli + Agent::Pi | Agent::Hermes | Agent::OpenCode | Agent::Kilo ) } @@ -52,10 +52,9 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> (source, agent_label), ("herdr:pi", "pi") | ("herdr:omp", "omp") - | ("herdr:kimi", "kimi") - | ("herdr:copilot", "copilot") | ("herdr:hermes", "hermes") - | ("herdr:qodercli", "qodercli") + | ("herdr:opencode", "opencode") + | ("herdr:kilo", "kilo") ) } @@ -122,23 +121,29 @@ mod tests { fn classifies_full_lifecycle_hook_sources() { assert!(full_lifecycle_hook_authority("herdr:pi", "pi")); assert!(full_lifecycle_hook_authority("herdr:omp", "omp")); - assert!(full_lifecycle_hook_authority("herdr:kimi", "kimi")); - assert!(full_lifecycle_hook_authority("herdr:copilot", "copilot")); assert!(full_lifecycle_hook_authority("herdr:hermes", "hermes")); - assert!(full_lifecycle_hook_authority("herdr:qodercli", "qodercli")); + assert!(full_lifecycle_hook_authority("herdr:opencode", "opencode")); + assert!(full_lifecycle_hook_authority("herdr:kilo", "kilo")); + assert!(!full_lifecycle_hook_authority("herdr:copilot", "copilot")); assert!(!full_lifecycle_hook_authority("herdr:codex", "codex")); assert!(!full_lifecycle_hook_authority("herdr:claude", "claude")); - assert!(!full_lifecycle_hook_authority("herdr:opencode", "opencode")); + assert!(!full_lifecycle_hook_authority("herdr:cursor", "cursor")); + assert!(!full_lifecycle_hook_authority("herdr:kimi", "kimi")); + assert!(!full_lifecycle_hook_authority("herdr:droid", "droid")); + assert!(!full_lifecycle_hook_authority("herdr:qodercli", "qodercli")); assert!(!full_lifecycle_hook_authority("custom", "pi")); } #[test] fn classifies_full_lifecycle_detected_agents_without_omp_variant() { assert!(full_lifecycle_detected_agent(Agent::Pi)); - assert!(full_lifecycle_detected_agent(Agent::Kimi)); - assert!(full_lifecycle_detected_agent(Agent::GithubCopilot)); assert!(full_lifecycle_detected_agent(Agent::Hermes)); - assert!(full_lifecycle_detected_agent(Agent::Qodercli)); + assert!(full_lifecycle_detected_agent(Agent::OpenCode)); + assert!(full_lifecycle_detected_agent(Agent::Kilo)); + assert!(!full_lifecycle_detected_agent(Agent::GithubCopilot)); + assert!(!full_lifecycle_detected_agent(Agent::Kimi)); + assert!(!full_lifecycle_detected_agent(Agent::Droid)); + assert!(!full_lifecycle_detected_agent(Agent::Qodercli)); assert!(!full_lifecycle_detected_agent(Agent::Codex)); assert!(!full_lifecycle_detected_agent(Agent::Claude)); } diff --git a/src/agent_resume.rs b/src/agent_resume.rs index d166aed2..b6a8c818 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -74,9 +74,11 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { (source, agent), ("herdr:claude", "claude") | ("herdr:codex", "codex") - | ("herdr:cursor", "cursor") + | ("herdr:copilot", "copilot") | ("herdr:droid", "droid") - | ("herdr:opencode", "opencode") + | ("herdr:kimi", "kimi") + | ("herdr:qodercli", "qodercli") + | ("herdr:cursor", "cursor") ) } @@ -123,6 +125,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:droid", "droid", AgentSessionRefKind::Id) => { vec!["droid".into(), "--resume".into(), session_ref.value.clone()] } + ("herdr:kimi", "kimi", AgentSessionRefKind::Id) => { + vec!["kimi".into(), "--session".into(), session_ref.value.clone()] + } ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { vec!["pi".into(), "--session".into(), session_ref.value.clone()] } @@ -140,6 +145,16 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< session_ref.value.clone(), ] } + ("herdr:qodercli", "qodercli", AgentSessionRefKind::Id) => { + vec![ + "qodercli".into(), + "--resume".into(), + session_ref.value.clone(), + ] + } + ("herdr:kilo", "kilo", AgentSessionRefKind::Id) => { + vec!["kilo".into(), "--session".into(), session_ref.value.clone()] + } ("herdr:cursor", "cursor", AgentSessionRefKind::Id) => { vec![ "cursor-agent".into(), @@ -171,9 +186,12 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:codex", "codex") | ("herdr:copilot", "copilot") | ("herdr:droid", "droid") + | ("herdr:kimi", "kimi") | ("herdr:pi", "pi") | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") + | ("herdr:qodercli", "qodercli") + | ("herdr:kilo", "kilo") | ("herdr:cursor", "cursor") ) } @@ -246,6 +264,16 @@ mod tests { ); assert_eq!( plan( + "herdr:kimi", + "kimi", + &AgentSessionRef::id("kimi-session").unwrap() + ) + .unwrap() + .argv, + vec!["kimi", "--session", "kimi-session"] + ); + assert_eq!( + plan( "herdr:pi", "pi", &AgentSessionRef::path(&pi_session).unwrap() @@ -276,6 +304,26 @@ mod tests { ); assert_eq!( plan( + "herdr:qodercli", + "qodercli", + &AgentSessionRef::id("qoder-session").unwrap() + ) + .unwrap() + .argv, + vec!["qodercli", "--resume", "qoder-session"] + ); + assert_eq!( + plan( + "herdr:kilo", + "kilo", + &AgentSessionRef::id("kilo-session").unwrap() + ) + .unwrap() + .argv, + vec!["kilo", "--session", "kilo-session"] + ); + assert_eq!( + plan( "herdr:cursor", "cursor", &AgentSessionRef::id("cursor-session").unwrap() @@ -349,6 +397,22 @@ mod tests { Some("/tmp/droid-session".into()) ) .is_none()); + + let session_ref = + session_ref_from_report("herdr:kimi", "kimi", Some("kimi-id".into()), None).unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "kimi-id"); + + let session_ref = + session_ref_from_report("herdr:kilo", "kilo", Some("kilo-id".into()), None).unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "kilo-id"); + + let session_ref = + session_ref_from_report("herdr:qodercli", "qodercli", Some("qoder-id".into()), None) + .unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "qoder-id"); } #[test] @@ -370,6 +434,7 @@ mod tests { fn planner_rejects_path_refs_for_id_only_agents() { let hermes_session = absolute_test_path("hermes-session"); let opencode_session = absolute_test_path("opencode-session"); + let kilo_session = absolute_test_path("kilo-session"); let copilot_session = absolute_test_path("copilot-session"); assert!(plan( "herdr:hermes", @@ -384,6 +449,12 @@ mod tests { ) .is_none()); assert!(plan( + "herdr:kilo", + "kilo", + &AgentSessionRef::path(&kilo_session).unwrap() + ) + .is_none()); + assert!(plan( "herdr:copilot", "copilot", &AgentSessionRef::path(&copilot_session).unwrap() @@ -404,6 +475,13 @@ mod tests { ) .is_some()); assert!(session_ref_from_snapshot( + "herdr:kilo", + "kilo", + AgentSessionRefKind::Id, + "kilo-session" + ) + .is_some()); + assert!(session_ref_from_snapshot( "herdr:copilot", "copilot", AgentSessionRefKind::Id, diff --git a/src/api/schema.rs b/src/api/schema.rs index 28be9c55..33040248 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -447,6 +447,7 @@ pub enum IntegrationTarget { Droid, Kimi, Opencode, + Kilo, Hermes, Qodercli, Cursor, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index a793e4de..e46e04b9 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result<Option<IntegrationTarget>> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" ); return Ok(None); } @@ -123,13 +123,14 @@ fn parse_integration_target( "droid" => IntegrationTarget::Droid, "kimi" => IntegrationTarget::Kimi, "opencode" => IntegrationTarget::Opencode, + "kilo" => IntegrationTarget::Kilo, "hermes" => IntegrationTarget::Hermes, "qodercli" => IntegrationTarget::Qodercli, "cursor" => IntegrationTarget::Cursor, _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, hermes, qodercli, cursor" + "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, kilo, hermes, qodercli, cursor" ); return Ok(None); } @@ -148,6 +149,7 @@ fn print_integration_help() { eprintln!(" herdr integration install droid"); eprintln!(" herdr integration install kimi"); eprintln!(" herdr integration install opencode"); + eprintln!(" herdr integration install kilo"); eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration install cursor"); @@ -159,6 +161,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall droid"); eprintln!(" herdr integration uninstall kimi"); eprintln!(" herdr integration uninstall opencode"); + eprintln!(" herdr integration uninstall kilo"); eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration uninstall cursor"); diff --git a/src/integration/assets/copilot/herdr-agent-state.ps1 b/src/integration/assets/copilot/herdr-agent-state.ps1 index 61cec0c9..8951eacb 100644 --- a/src/integration/assets/copilot/herdr-agent-state.ps1 +++ b/src/integration/assets/copilot/herdr-agent-state.ps1 @@ -2,10 +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=copilot -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } $inputText = [Console]::In.ReadToEnd() try { @@ -31,74 +32,21 @@ function Normalize-Event { return $Event.Replace("_", "").Replace("-", "").ToLowerInvariant() } -function Infer-Event { - $explicit = First-Text @("hook_event_name", "hookEventName") - if ($explicit) { return $explicit } - if (First-Text @("notification_type", "notificationType")) { return "notification" } - if ($payload.PSObject.Properties.Name -contains "toolResult" -or $payload.PSObject.Properties.Name -contains "tool_result") { return "postToolUse" } - if (($payload.PSObject.Properties.Name -contains "error") -and (First-Text @("tool_name", "toolName"))) { return "postToolUseFailure" } - if (First-Text @("tool_name", "toolName")) { return "preToolUse" } - if (First-Text @("stop_reason", "stopReason")) { return "agentStop" } - if (First-Text @("reason")) { return "sessionEnd" } - if ($payload.PSObject.Properties.Name -contains "prompt") { return "userPromptSubmitted" } - if ( - $payload.PSObject.Properties.Name -contains "initial_prompt" -or - $payload.PSObject.Properties.Name -contains "initialPrompt" -or - $payload.PSObject.Properties.Name -contains "source" -or - (First-Text @("session_id", "sessionId")) - ) { return "sessionStart" } - return "" +$eventName = First-Text @("hook_event_name", "hookEventName") +if ($eventName) { + if ((Normalize-Event $eventName) -ne "sessionstart") { exit 0 } +} elseif ( + ($payload.PSObject.Properties.Name -contains "prompt") -or + (First-Text @("tool_name", "toolName", "notification_type", "notificationType", "stop_reason", "stopReason", "reason")) +) { + exit 0 } -function Has-Initial-Prompt { - $value = $payload.initial_prompt - if ($null -eq $value) { $value = $payload.initialPrompt } - return ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) +$sessionId = $payload.session_id +if ([string]::IsNullOrWhiteSpace($sessionId)) { + $sessionId = $payload.sessionId } +if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } -function Report-State { - param([string]$State, [string]$SessionId) - $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:copilot", "--agent", "copilot", "--state", $State, "--seq", "$seq") - if (-not [string]::IsNullOrWhiteSpace($SessionId)) { - $args += @("--agent-session-id", $SessionId) - } - & herdr @args 2>$null | Out-Null -} - -function Release-Agent { - $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:copilot --agent copilot --seq $seq 2>$null | Out-Null -} - -try { - $eventKey = Normalize-Event (Infer-Event) - $sessionId = First-Text @("session_id", "sessionId") - $toolName = First-Text @("tool_name", "toolName") - $notificationType = First-Text @("notification_type", "notificationType") - $stopReason = First-Text @("stop_reason", "stopReason") - $reason = First-Text @("reason") - - if ($eventKey -eq "sessionstart") { - Report-State ($(if (Has-Initial-Prompt) { "working" } else { "idle" })) $sessionId - } elseif ($eventKey -in @("userpromptsubmit", "userpromptsubmitted")) { - Report-State "working" $sessionId - } elseif ($eventKey -eq "pretooluse") { - Report-State ($(if ($toolName -in @("ask_user", "exit_plan_mode")) { "blocked" } else { "working" })) $sessionId - } elseif ($eventKey -in @("posttooluse", "posttoolusefailure")) { - if ($toolName -ne "report_intent") { Report-State "working" $sessionId } - } elseif ($eventKey -eq "notification") { - if ($notificationType -in @("permission_prompt", "elicitation_dialog")) { - Report-State "blocked" $sessionId - } elseif ($notificationType -eq "agent_idle") { - Report-State "idle" $sessionId - } - } elseif ($eventKey -in @("stop", "agentstop", "sessionstop")) { - if ([string]::IsNullOrWhiteSpace($stopReason) -or $stopReason -eq "end_turn") { - Report-State "idle" $sessionId - } - } elseif ($eventKey -eq "sessionend") { - if ($reason -in @("user_exit", "abort")) { Release-Agent } - } -} catch { -} +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +& herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:copilot --agent copilot --agent-session-id $sessionId --seq $seq 2>$null | Out-Null diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/src/integration/assets/copilot/herdr-agent-state.sh index 766b562a..1ae0362b 100644 --- a/src/integration/assets/copilot/herdr-agent-state.sh +++ b/src/integration/assets/copilot/herdr-agent-state.sh @@ -2,18 +2,8 @@ # installed by herdr # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. -# High-level state machine: -# - SessionStart reports working when Copilot includes an initial prompt, else idle. -# - UserPromptSubmit reports working for the active turn. -# - PreToolUse reports blocked for ask_user and exit_plan_mode, else working. -# - notification permission_prompt / elicitation_dialog reports blocked. -# - PostToolUse / PostToolUseFailure clears handled blockers back to working. -# - agentStop / Stop with end_turn reports idle. -# - SessionEnd only releases ownership for real exits such as user_exit / abort. -# Official Copilot hooks reference: -# https://docs.github.com/en/copilot/reference/hooks-reference # HERDR_INTEGRATION_ID=copilot -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu @@ -34,7 +24,6 @@ import socket import time source = "herdr:copilot" -agent = "copilot" pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") @@ -64,120 +53,41 @@ def first_text(*keys): def normalize_event(event): return event.replace("_", "").replace("-", "").lower() -def infer_event(): - explicit = first_text("hook_event_name", "hookEventName") - if explicit: - return explicit - if first_text("notification_type", "notificationType"): - return "notification" - if "toolResult" in hook_input or "tool_result" in hook_input: - return "postToolUse" - if "error" in hook_input and first_text("tool_name", "toolName"): - return "postToolUseFailure" - if first_text("tool_name", "toolName"): - return "preToolUse" - if first_text("stop_reason", "stopReason"): - return "agentStop" - if first_text("reason"): - return "sessionEnd" - if "prompt" in hook_input: - return "userPromptSubmitted" - if ( - "initial_prompt" in hook_input - or "initialPrompt" in hook_input - or "source" in hook_input - or first_text("session_id", "sessionId") - ): - return "sessionStart" - return "" - -def has_initial_prompt(): - value = hook_input.get("initial_prompt", hook_input.get("initialPrompt")) - return isinstance(value, str) and bool(value.strip()) +event = first_text("hook_event_name", "hookEventName") +if event: + if normalize_event(event) != "sessionstart": + raise SystemExit(0) +elif "prompt" in hook_input or first_text("tool_name", "toolName", "notification_type", "notificationType", "stop_reason", "stopReason", "reason"): + raise SystemExit(0) -def is_user_blocking_tool(name): - return name in ("ask_user", "exit_plan_mode") +session_id = hook_input.get("session_id") +if not isinstance(session_id, str) or not session_id: + session_id = hook_input.get("sessionId") +if not isinstance(session_id, str) or not session_id: + raise SystemExit(0) -def is_ignored_post_tool(name): - # report_intent can arrive after ask_user has already blocked the turn but - # before the user answers. Suppress it so it cannot clear that blocker. - return name in ("report_intent",) +request = { + "id": f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}", + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "copilot", + "agent_session_id": session_id, + "seq": time.time_ns(), + }, +} -def send(method, params): - request = { - "id": f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}", - "method": method, - "params": { - "pane_id": pane_id, - "source": source, - "agent": agent, - "seq": time.time_ns(), - **params, - }, - } +try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((json.dumps(request) + "\n").encode("utf-8")) try: - client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - client.settimeout(0.5) - client.connect(socket_path) - client.sendall((json.dumps(request) + "\n").encode("utf-8")) - try: - client.recv(4096) - except Exception: - pass - client.close() + client.recv(4096) except Exception: pass - -def report(state, session_id): - params = {"state": state} - if session_id: - params["agent_session_id"] = session_id - send("pane.report_agent", params) - -def release(): - send("pane.release_agent", {}) - -try: - event = infer_event() - event_key = normalize_event(event) - session_id = first_text("session_id", "sessionId") - tool_name = first_text("tool_name", "toolName") - notification_type = first_text("notification_type", "notificationType") - stop_reason = first_text("stop_reason", "stopReason") - reason = first_text("reason") - - if event_key == "sessionstart": - report("working" if has_initial_prompt() else "idle", session_id) - elif event_key in ("userpromptsubmit", "userpromptsubmitted"): - report("working", session_id) - elif event_key == "pretooluse": - report("blocked" if is_user_blocking_tool(tool_name) else "working", session_id) - elif event_key in ("posttooluse", "posttoolusefailure"): - if is_ignored_post_tool(tool_name): - pass - else: - report("working", session_id) - elif event_key == "notification": - if notification_type in ("permission_prompt", "elicitation_dialog"): - report("blocked", session_id) - elif notification_type == "agent_idle": - report("idle", session_id) - else: - pass - elif event_key in ("stop", "agentstop", "sessionstop"): - if not stop_reason or stop_reason == "end_turn": - report("idle", session_id) - else: - pass - elif event_key == "sessionend": - # Copilot can emit reason=complete at normal turn boundaries. Keep - # ownership until a real session exit so Herdr can still track later turns. - if reason in ("user_exit", "abort"): - release() - else: - pass - else: - pass + client.close() except Exception: pass PY diff --git a/src/integration/assets/droid/herdr-agent-state.ps1 b/src/integration/assets/droid/herdr-agent-state.ps1 index ad035d72..f3bb0f68 100644 --- a/src/integration/assets/droid/herdr-agent-state.ps1 +++ b/src/integration/assets/droid/herdr-agent-state.ps1 @@ -2,8 +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=droid -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } @@ -11,16 +14,13 @@ $inputText = [Console]::In.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { - exit 0 + $payload = $null } -if ($payload.hook_event_name -ne "SessionStart") { exit 0 } - -$sessionId = $payload.session_id -if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() try { - & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:droid --agent droid --seq $seq --agent-session-id $sessionId 2>$null | Out-Null + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:droid --agent droid --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null } catch { } diff --git a/src/integration/assets/droid/herdr-agent-state.sh b/src/integration/assets/droid/herdr-agent-state.sh index 14109fc2..ddab463e 100644 --- a/src/integration/assets/droid/herdr-agent-state.sh +++ b/src/integration/assets/droid/herdr-agent-state.sh @@ -3,20 +3,26 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=droid -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu +action="${1:-}" hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-droid-hook.XXXXXX")" || exit 0 trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM cat >"$hook_input_file" 2>/dev/null || true +case "$action" in + session) ;; + *) exit 0 ;; +esac + [ "${HERDR_ENV:-}" = "1" ] || exit 0 [ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' import json import os import random @@ -41,15 +47,12 @@ if hook_input_file: except Exception: hook_input = {} -if hook_input.get("hook_event_name") != "SessionStart": - raise SystemExit(0) - session_id = hook_input.get("session_id") -agent_session_id = session_id if isinstance(session_id, str) and session_id else None -if not agent_session_id: +if not isinstance(session_id, str) or not session_id: raise SystemExit(0) request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() request = { "id": request_id, "method": "pane.report_agent_session", @@ -57,8 +60,8 @@ request = { "pane_id": pane_id, "source": source, "agent": "droid", - "seq": time.time_ns(), - "agent_session_id": agent_session_id, + "agent_session_id": session_id, + "seq": report_seq, }, } diff --git a/src/integration/assets/kilo/herdr-agent-state.js b/src/integration/assets/kilo/herdr-agent-state.js new file mode 100644 index 00000000..aa1a0c1b --- /dev/null +++ b/src/integration/assets/kilo/herdr-agent-state.js @@ -0,0 +1,159 @@ +// installed by herdr +// 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=kilo +// HERDR_INTEGRATION_VERSION=1 + +import net from "node:net"; + +const SOURCE = "herdr:kilo"; +const AGENT = "kilo"; +let reportSeq = Date.now() * 1000; + +function nextReportSeq() { + reportSeq += 1; + return reportSeq; +} + +function sessionIDFromProperties(properties) { + return typeof properties?.sessionID === "string" && properties.sessionID + ? properties.sessionID + : undefined; +} + +function stateFromSessionStatus(status) { + if (typeof status !== "string") { + return undefined; + } + switch (status.toLowerCase()) { + case "idle": + return "idle"; + case "active": + case "busy": + case "pending": + case "running": + case "streaming": + case "working": + return "working"; + default: + return undefined; + } +} + +function request(method, params) { + const paneId = process.env.HERDR_PANE_ID; + const socketPath = process.env.HERDR_SOCKET_PATH; + + if (!paneId || !socketPath) { + return Promise.resolve(); + } + + const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000) + .toString() + .padStart(6, "0")}`; + const request = { + id: requestId, + method, + params: { + pane_id: paneId, + source: SOURCE, + agent: AGENT, + seq: nextReportSeq(), + ...params, + }, + }; + + return new Promise((resolve) => { + const client = net.createConnection(socketPath, () => { + client.write(`${JSON.stringify(request)}\n`); + }); + + const finish = () => { + client.destroy(); + resolve(); + }; + + client.setTimeout(500, finish); + client.on("data", finish); + client.on("error", finish); + client.on("end", finish); + client.on("close", resolve); + }); +} + +function reportSession(sessionID) { + if (!sessionID) { + return Promise.resolve(); + } + return request("pane.report_agent_session", { agent_session_id: sessionID }); +} + +function reportState(state, sessionID) { + const params = { state }; + if (sessionID) { + params.agent_session_id = sessionID; + } + return request("pane.report_agent", params); +} + +function releaseAgent() { + return request("pane.release_agent", {}); +} + +export const HerdrAgentStatePlugin = async () => { + if ( + process.env.HERDR_ENV !== "1" || + !process.env.HERDR_SOCKET_PATH || + !process.env.HERDR_PANE_ID + ) { + return {}; + } + + return { + "chat.message": async ({ sessionID }) => { + await reportState("working", sessionID); + }, + event: async ({ event }) => { + const type = event?.type; + const properties = event?.properties ?? {}; + const sessionID = sessionIDFromProperties(properties); + + switch (type) { + case "session.created": + case "session.updated": + await reportSession(sessionID); + break; + case "session.status": { + const state = stateFromSessionStatus(properties.status); + if (state) { + await reportState(state, sessionID); + } else { + await reportSession(sessionID); + } + break; + } + case "tool.execute.before": + case "tool.execute.after": + case "permission.replied": + case "question.replied": + case "question.rejected": + case "session.compacted": + await reportState("working", sessionID); + break; + case "permission.asked": + case "question.asked": + case "session.error": + await reportState("blocked", sessionID); + break; + case "session.idle": + await reportState("idle", sessionID); + break; + case "session.deleted": + await releaseAgent(); + break; + default: + break; + } + }, + }; +}; diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1 index a230a627..6684d2a9 100644 --- a/src/integration/assets/kimi/herdr-agent-state.ps1 +++ b/src/integration/assets/kimi/herdr-agent-state.ps1 @@ -2,21 +2,25 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") -if ($Action -notin @("working", "idle", "blocked", "release")) { 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 } -[Console]::In.ReadToEnd() | Out-Null +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} + +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } + $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() try { - if ($Action -eq "release") { - & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --seq $seq 2>$null | Out-Null - } else { - & herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --seq $seq 2>$null | Out-Null - } + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null } catch { } diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh index 3e0a9c29..6332ca2a 100644 --- a/src/integration/assets/kimi/herdr-agent-state.sh +++ b/src/integration/assets/kimi/herdr-agent-state.sh @@ -3,15 +3,17 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu action="${1:-}" -cat >/dev/null 2>/dev/null || true +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-kimi-hook.XXXXXX")" || exit 0 +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 @@ -20,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' import json import os import random @@ -28,38 +30,40 @@ import socket import time source = "herdr:kimi" -action = os.environ.get("HERDR_ACTION", "") pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") if not pane_id or not socket_path: raise SystemExit(0) +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + +session_id = hook_input.get("session_id") +if not isinstance(session_id, str) or not session_id: + raise SystemExit(0) + request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() -if action == "release": - request = { - "id": request_id, - "method": "pane.release_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "kimi", - "seq": report_seq, - }, - } -else: - request = { - "id": request_id, - "method": "pane.report_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "kimi", - "state": action, - "seq": report_seq, - }, - } +request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "kimi", + "agent_session_id": session_id, + "seq": report_seq, + }, +} 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 c2fd79a6..1e9fff51 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -2,11 +2,12 @@ // 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=4 +// HERDR_INTEGRATION_VERSION=5 import net from "node:net"; const SOURCE = "herdr:opencode"; +const AGENT = "opencode"; let reportSeq = Date.now() * 1000; function nextReportSeq() { @@ -20,10 +21,26 @@ function sessionIDFromProperties(properties) { : undefined; } -function reportSession(sessionID) { - if (!sessionID) { - return Promise.resolve(); +function stateFromSessionStatus(status) { + if (typeof status !== "string") { + return undefined; } + switch (status.toLowerCase()) { + case "idle": + return "idle"; + case "active": + case "busy": + case "pending": + case "running": + case "streaming": + case "working": + return "working"; + default: + return undefined; + } +} + +function request(method, params) { const paneId = process.env.HERDR_PANE_ID; const socketPath = process.env.HERDR_SOCKET_PATH; @@ -36,13 +53,13 @@ function reportSession(sessionID) { .padStart(6, "0")}`; const request = { id: requestId, - method: "pane.report_agent_session", + method, params: { pane_id: paneId, source: SOURCE, - agent: "opencode", + agent: AGENT, seq: nextReportSeq(), - agent_session_id: sessionID, + ...params, }, }; @@ -64,6 +81,25 @@ function reportSession(sessionID) { }); } +function reportSession(sessionID) { + if (!sessionID) { + return Promise.resolve(); + } + return request("pane.report_agent_session", { agent_session_id: sessionID }); +} + +function reportState(state, sessionID) { + const params = { state }; + if (sessionID) { + params.agent_session_id = sessionID; + } + return request("pane.report_agent", params); +} + +function releaseAgent() { + return request("pane.release_agent", {}); +} + export const HerdrAgentStatePlugin = async () => { if ( process.env.HERDR_ENV !== "1" || @@ -74,6 +110,9 @@ export const HerdrAgentStatePlugin = async () => { } return { + "chat.message": async ({ sessionID }) => { + await reportState("working", sessionID); + }, event: async ({ event }) => { const type = event?.type; const properties = event?.properties ?? {}; @@ -82,9 +121,36 @@ export const HerdrAgentStatePlugin = async () => { switch (type) { case "session.created": case "session.updated": - case "session.status": await reportSession(sessionID); break; + case "session.status": { + const state = stateFromSessionStatus(properties.status); + if (state) { + await reportState(state, sessionID); + } else { + await reportSession(sessionID); + } + break; + } + case "tool.execute.before": + case "tool.execute.after": + case "permission.replied": + case "question.replied": + case "question.rejected": + case "session.compacted": + await reportState("working", sessionID); + break; + case "permission.asked": + case "question.asked": + case "session.error": + await reportState("blocked", sessionID); + break; + case "session.idle": + await reportState("idle", sessionID); + break; + case "session.deleted": + await releaseAgent(); + break; default: break; } diff --git a/src/integration/assets/qodercli/herdr-agent-state.ps1 b/src/integration/assets/qodercli/herdr-agent-state.ps1 index f7102817..df527f6d 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.ps1 +++ b/src/integration/assets/qodercli/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=qodercli -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") -if ($Action -notin @("working", "idle", "blocked", "release")) { 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 } @@ -17,19 +17,10 @@ try { $payload = $null } -if ($payload.hook_event_name -eq "SubagentStop") { exit 0 } -if ($payload.agent_id -and $Action -in @("idle", "release")) { exit 0 } +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() try { - if ($Action -eq "release") { - & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:qodercli --agent qodercli --seq $seq 2>$null | Out-Null - } else { - $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:qodercli", "--agent", "qodercli", "--state", $Action, "--seq", "$seq") - if (-not [string]::IsNullOrWhiteSpace($payload.session_id)) { - $args += @("--agent-session-id", $payload.session_id) - } - & herdr @args 2>$null | Out-Null - } + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:qodercli --agent qodercli --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null } catch { } diff --git a/src/integration/assets/qodercli/herdr-agent-state.sh b/src/integration/assets/qodercli/herdr-agent-state.sh index 8903c45c..b55d8194 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.sh +++ b/src/integration/assets/qodercli/herdr-agent-state.sh @@ -3,17 +3,15 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=qodercli -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 # -# Reports qodercli agent state changes to herdr. Registered as a Command hook +# Reports qodercli session identity to herdr. Registered as a Command hook # in ~/.qoder/settings.json by `herdr integration install qodercli` and -# invoked by qodercli's hook system on lifecycle events. +# invoked by qodercli's hook system on session start. # # qodercli (per https://docs.qoder.com/zh/cli/hooks) sends a JSON payload on -# stdin describing the hook event. The event name is read from the stdin -# payload's `hook_event_name` field, the same way -# `assets/claude/herdr-agent-state.sh` already consumes claude code's stdin -# payload. No environment variable is consulted for the event identity. +# stdin describing the hook event. This hook reads the session id from that +# payload. set -eu @@ -23,7 +21,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 @@ -40,7 +38,6 @@ import socket import time source = "herdr:qodercli" -action = os.environ.get("HERDR_ACTION", "") pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") @@ -58,46 +55,23 @@ if hook_input_file: except Exception: hook_input = {} -# Per docs.qoder.com/zh/cli/hooks the payload always carries `hook_event_name`. -hook_event_name = str(hook_input.get("hook_event_name") or "") -is_subagent = bool(hook_input.get("agent_id")) -if hook_event_name == "SubagentStop": - # SubagentStop is a completion event; never let it revive an idle pane the - # way the parallel claude integration does. - raise SystemExit(0) -if is_subagent and action in ("idle", "release"): - # Subagent completion must not make the parent pane look done early. +session_id = hook_input.get("session_id") +if not isinstance(session_id, str) or not session_id: 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": - request = { - "id": request_id, - "method": "pane.release_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "qodercli", - "seq": report_seq, - }, - } -else: - request = { - "id": request_id, - "method": "pane.report_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "qodercli", - "state": action, - "seq": report_seq, - }, - } - if agent_session_id: - request["params"]["agent_session_id"] = agent_session_id +request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "qodercli", + "agent_session_id": session_id, + "seq": report_seq, + }, +} try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 07318213..36178b8d 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -51,23 +51,12 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/kimi/herdr-agent-state.sh") }; -const KIMI_INTEGRATION_VERSION: u32 = 1; +const KIMI_INTEGRATION_VERSION: u32 = 2; const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; const KIMI_MIN_VERSION: &str = "0.8.0"; -const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [ - ("SessionStart", "idle"), - ("UserPromptSubmit", "working"), - ("PreToolUse", "working"), - ("PermissionRequest", "blocked"), - ("PermissionResult", "working"), - ("PostToolUse", "working"), - ("PostToolUseFailure", "working"), - ("Stop", "idle"), - ("StopFailure", "idle"), - ("SessionEnd", "release"), -]; +const KIMI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -78,8 +67,20 @@ const COPILOT_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/copilot/herdr-agent-state.sh") }; -const COPILOT_INTEGRATION_VERSION: u32 = 1; +const COPILOT_INTEGRATION_VERSION: u32 = 2; const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; +const COPILOT_HOOK_EVENTS: [&str; 1] = ["SessionStart"]; +const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "agentStop", + "SessionEnd", + "notification", + "sessionStart", +]; const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -90,10 +91,25 @@ const DROID_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/droid/herdr-agent-state.sh") }; -const DROID_INTEGRATION_VERSION: u32 = 1; +const DROID_INTEGRATION_VERSION: u32 = 2; +const DROID_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("Notification", "blocked"), + ("Stop", "idle"), + ("SubagentStop", "working"), + ("PreCompact", "working"), + ("SessionEnd", "release"), +]; 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 = 4; +const OPENCODE_INTEGRATION_VERSION: u32 = 5; +const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; +const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js"); +const KILO_INTEGRATION_VERSION: u32 = 1; 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"; @@ -110,8 +126,23 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/qodercli/herdr-agent-state.sh") }; -const QODERCLI_INTEGRATION_VERSION: u32 = 1; +const QODERCLI_INTEGRATION_VERSION: u32 = 2; const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; +const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PostToolUseFailure", "working"), + ("SubagentStart", "working"), + ("SubagentStop", "working"), + ("PreCompact", "working"), + ("Notification", "blocked"), + ("PermissionRequest", "blocked"), + ("Stop", "idle"), + ("SessionEnd", "release"), +]; const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh"); const CURSOR_INTEGRATION_VERSION: u32 = 1; @@ -157,6 +188,11 @@ pub(crate) struct OpenCodeInstallPaths { } #[derive(Debug)] +pub(crate) struct KiloInstallPaths { + pub plugin_path: PathBuf, +} + +#[derive(Debug)] pub(crate) struct OmpInstallPaths { pub extension_path: PathBuf, pub removed_legacy_pi_extension: bool, @@ -300,6 +336,12 @@ pub(crate) struct OpenCodeUninstallResult { } #[derive(Debug)] +pub(crate) struct KiloUninstallResult { + pub plugin_path: PathBuf, + pub removed_plugin: bool, +} + +#[derive(Debug)] pub(crate) struct HermesUninstallResult { pub plugin_dir: PathBuf, pub config_path: PathBuf, @@ -423,6 +465,13 @@ pub(crate) fn install_target( installed.plugin_path.display() )] } + crate::api::schema::IntegrationTarget::Kilo => { + let installed = install_kilo()?; + vec![format!( + "installed kilo integration plugin to {}", + installed.plugin_path.display() + )] + } crate::api::schema::IntegrationTarget::Hermes => { let installed = install_hermes()?; vec![ @@ -661,6 +710,20 @@ pub(crate) fn uninstall_target( )] } } + crate::api::schema::IntegrationTarget::Kilo => { + let result = uninstall_kilo()?; + if result.removed_plugin { + vec![format!( + "removed kilo integration plugin at {}", + result.plugin_path.display() + )] + } else { + vec![format!( + "no kilo integration plugin found at {}", + result.plugin_path.display() + )] + } + } crate::api::schema::IntegrationTarget::Hermes => { let result = uninstall_hermes()?; let mut messages = Vec::new(); @@ -760,6 +823,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Droid => "droid", crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", + crate::api::schema::IntegrationTarget::Kilo => "kilo", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", crate::api::schema::IntegrationTarget::Cursor => "cursor", @@ -782,6 +846,7 @@ fn integration_target_command_names( crate::api::schema::IntegrationTarget::Droid => &["droid"], crate::api::schema::IntegrationTarget::Kimi => &["kimi"], crate::api::schema::IntegrationTarget::Opencode => &["opencode"], + crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"], crate::api::schema::IntegrationTarget::Hermes => &["hermes"], crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), @@ -989,7 +1054,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 11] { +); 12] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -1032,6 +1097,11 @@ fn integration_specs() -> [( OPENCODE_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::Kilo, + kilo_dir().map(|dir| dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME)), + KILO_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Hermes, hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)), HERMES_INTEGRATION_VERSION, @@ -1385,34 +1455,15 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { "copilot settings hooks", )?; let command = hook_command(&hook_path, None); - for event in [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "Stop", - "agentStop", - "SessionEnd", - "notification", - ] { + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { remove_direct_hook_commands(hooks, event, &hook_path, None)?; } - ensure_direct_command_hook(hooks, "SessionStart", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "UserPromptSubmit", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "PreToolUse", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "PostToolUse", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "PostToolUseFailure", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "Stop", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "agentStop", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "SessionEnd", command.clone(), 10, None)?; - ensure_direct_command_hook( - hooks, - "notification", - command, - 10, - Some("permission_prompt|elicitation_dialog|agent_idle"), - )?; + for event in COPILOT_HOOK_EVENTS { + remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + for event in COPILOT_HOOK_EVENTS { + ensure_direct_command_hook(hooks, event, command.clone(), 10, None)?; + } remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1458,13 +1509,21 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { "droid settings hooks", )?; remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; - ensure_command_hook( - hooks, - "SessionStart", - hook_command(&hook_path, None), - 10, - None, - )?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + None, + )?; + } remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1483,6 +1542,14 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { "droid hooks file hooks", )? { updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_legacy_hooks |= + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_legacy_hooks |= + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_legacy_hooks { fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; @@ -1515,6 +1582,24 @@ pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> { Ok(OpenCodeInstallPaths { plugin_path }) } +pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> { + let dir = kilo_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "kilo config directory not found at {}. install kilo first", + dir.display() + ))); + } + + let plugins_dir = dir.join("plugin"); + fs::create_dir_all(&plugins_dir)?; + + let plugin_path = plugins_dir.join(KILO_PLUGIN_INSTALL_NAME); + fs::write(&plugin_path, KILO_PLUGIN_ASSET)?; + + Ok(KiloInstallPaths { plugin_path }) +} + pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> { let dir = hermes_dir()?; if !dir.is_dir() { @@ -1724,20 +1809,12 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { "copilot settings", "copilot settings hooks", )? { - updated_settings |= - remove_direct_hook_commands(hooks, "SessionStart", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "UserPromptSubmit", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "PreToolUse", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "PostToolUse", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "PostToolUseFailure", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "Stop", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "agentStop", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "SessionEnd", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "notification", &hook_path, None)?; + for event in COPILOT_HOOK_EVENTS { + updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } } if updated_settings { @@ -1776,6 +1853,12 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { "droid hooks file hooks", )? { updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_hooks { @@ -1798,6 +1881,12 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { "droid settings hooks", )? { updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_settings { @@ -1830,6 +1919,16 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { }) } +pub(crate) fn uninstall_kilo() -> io::Result<KiloUninstallResult> { + let plugin_path = kilo_dir()?.join("plugin").join(KILO_PLUGIN_INSTALL_NAME); + let removed_plugin = remove_file_if_exists(&plugin_path)?; + + Ok(KiloUninstallResult { + plugin_path, + removed_plugin, + }) +} + pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> { let dir = hermes_dir()?; let plugin_dir = hermes_plugin_dir()?; @@ -1874,9 +1973,7 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { // settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level // `hooks` object keyed by event name, each entry holding a matcher + a // list of `{type: "command", command, timeout?}` invocations. The hook - // script reads the event payload from stdin via `hook_event_name` so the - // installation never depends on a `QODER_HOOK_EVENT` environment - // variable. + // script reads the event payload from stdin via `hook_event_name`. let settings_path = dir.join("settings.json"); let mut settings = if settings_path.is_file() { serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { @@ -1895,57 +1992,21 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { "qodercli settings", "qodercli settings hooks", )?; - // SubagentStop is intentionally *not* mapped to working: the hook script - // returns early on it (mirroring assets/claude/herdr-agent-state.sh) so - // that recap/away-summary frames cannot revive an idle pane. - remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; - remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; - remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; - remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; - remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; - remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; - ensure_command_hook( - hooks, - "SessionStart", - hook_command(&hook_path, Some("idle")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "UserPromptSubmit", - hook_command(&hook_path, Some("working")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "PreToolUse", - hook_command(&hook_path, Some("working")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "PermissionRequest", - hook_command(&hook_path, Some("blocked")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "Stop", - hook_command(&hook_path, Some("idle")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "SessionEnd", - hook_command(&hook_path, Some("release")), - 10, - Some("*"), - )?; + for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + Some("*"), + )?; + } remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -2035,17 +2096,12 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { "qodercli settings", "qodercli settings hooks", )? { - updated_settings |= - remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; - updated_settings |= - remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; - updated_settings |= - remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; - updated_settings |= - remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; - updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; - updated_settings |= - remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; + for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_settings { @@ -2206,8 +2262,8 @@ fn ensure_command_hook( // Claude and Codex use nested hook groups: // { "matcher": "...", "hooks": [{ "type": "command", ... }] } -// Copilot and Qoder CLI use the flatter settings shape: -// { "type": "command", "matcher": "...", "command": "...", ... } +// Copilot uses the flatter settings shape: +// { "type": "command", "matcher": "...", "bash": "...", ... } // Keep the helpers separate so install/uninstall preserves unrelated hooks in // each agent's native format instead of normalizing user configuration. fn ensure_direct_command_hook( @@ -2223,13 +2279,18 @@ fn ensure_direct_command_hook( .as_array_mut() .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + let command_field = direct_command_field(); if let Some(entry) = entries.iter_mut().find(|entry| { entry.get("type").and_then(Value::as_str) == Some("command") - && entry.get("command").and_then(Value::as_str) == Some(command.as_str()) + && is_matching_direct_command_entry(entry, command.as_str()) }) { let Some(entry_object) = entry.as_object_mut() else { return Ok(()); }; + entry_object.remove("command"); + entry_object.remove("bash"); + entry_object.remove("powershell"); + entry_object.insert(command_field.to_string(), Value::String(command.clone())); entry_object.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into())); match matcher { Some(matcher) => { @@ -2247,12 +2308,26 @@ fn ensure_direct_command_hook( if let Some(matcher) = matcher { entry.insert("matcher".to_string(), Value::String(matcher.to_string())); } - entry.insert("command".to_string(), Value::String(command)); + entry.insert(command_field.to_string(), Value::String(command)); entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into())); entries.push(Value::Object(entry)); Ok(()) } +fn direct_command_field() -> &'static str { + if cfg!(windows) { + "powershell" + } else { + "bash" + } +} + +fn is_matching_direct_command_entry(entry: &Value, command: &str) -> bool { + entry.get("command").and_then(Value::as_str) == Some(command) + || entry.get("bash").and_then(Value::as_str) == Some(command) + || entry.get("powershell").and_then(Value::as_str) == Some(command) +} + fn remove_command_hook( hooks: &mut Map<String, Value>, event: &str, @@ -2311,7 +2386,7 @@ fn remove_direct_command_hook( let before = entries.len(); entries.retain(|entry| { !(entry.get("type").and_then(Value::as_str) == Some("command") - && entry.get("command").and_then(Value::as_str) == Some(command)) + && is_matching_direct_command_entry(entry, command)) }); let removed = entries.len() != before; if entries.is_empty() { @@ -3061,6 +3136,10 @@ fn opencode_dir() -> io::Result<PathBuf> { Ok(home_dir()?.join(".config/opencode")) } +fn kilo_dir() -> io::Result<PathBuf> { + Ok(home_dir()?.join(".config/kilo")) +} + fn hermes_dir() -> io::Result<PathBuf> { Ok(home_dir()?.join(".hermes")) } @@ -3192,6 +3271,7 @@ mod tests { assert!(!integration_target_supported(IntegrationTarget::Pi)); assert!(!integration_target_supported(IntegrationTarget::Omp)); assert!(!integration_target_supported(IntegrationTarget::Opencode)); + assert!(!integration_target_supported(IntegrationTarget::Kilo)); assert!(!integration_target_supported(IntegrationTarget::Hermes)); assert!(!integration_target_supported(IntegrationTarget::Cursor)); @@ -3218,12 +3298,14 @@ mod tests { fs::write(bin.join("pi.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("omp.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("opencode.cmd"), "@echo off\r\n").unwrap(); + fs::write(bin.join("kilo.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("hermes.exe"), "").unwrap(); fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap(); assert!(!integration_target_available(IntegrationTarget::Pi)); assert!(!integration_target_available(IntegrationTarget::Omp)); assert!(!integration_target_available(IntegrationTarget::Opencode)); + assert!(!integration_target_available(IntegrationTarget::Kilo)); assert!(!integration_target_available(IntegrationTarget::Hermes)); assert!(!integration_target_available(IntegrationTarget::Cursor)); @@ -4362,7 +4444,7 @@ mod tests { ); assert_eq!(installed.config_path, kimi_dir.join("config.toml")); assert_eq!(hook_content, KIMI_HOOK_ASSET); - assert_eq!(hooks.len(), 11); + assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len() + 1); assert!(config.contains("default_model = \"moonshot\"")); assert!(config.contains("command = \"echo keep\"")); assert!(config.contains(KIMI_CONFIG_BLOCK_BEGIN)); @@ -4481,9 +4563,17 @@ mod tests { let home = base.join("home"); let copilot_dir = home.join(".copilot"); fs::create_dir_all(&copilot_dir).unwrap(); + let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME); + let stale_session_start_command = format!( + "bash {}", + shell_single_quote(&hook_path.display().to_string()) + ); fs::write( copilot_dir.join("settings.json"), - r#"{"theme":"dark","hooks":{"PreToolUse":[{"type":"command","command":"echo keep","timeoutSec":10}]}}"#, + format!( + r#"{{"theme":"dark","hooks":{{"PreToolUse":[{{"type":"command","command":"echo keep","timeoutSec":10}}],"sessionStart":[{{"type":"command","bash":{},"timeoutSec":10}}]}}}}"#, + serde_json::to_string(&stale_session_start_command).unwrap() + ), ) .unwrap(); std::env::set_var("HOME", &home); @@ -4500,35 +4590,28 @@ mod tests { assert_eq!(installed.settings_path, copilot_dir.join("settings.json")); assert_eq!(hook_content, COPILOT_HOOK_ASSET); assert_eq!(settings["theme"], "dark"); - assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 2); + assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep"); - assert!(settings["hooks"]["PreToolUse"][1]["command"] - .as_str() - .unwrap() - .contains(COPILOT_HOOK_INSTALL_NAME)); - assert!(settings["hooks"]["PostToolUse"][0].get("matcher").is_none()); - assert!(settings["hooks"]["PostToolUseFailure"][0] - .get("matcher") - .is_none()); - assert_eq!( - settings["hooks"]["notification"][0]["matcher"], - "permission_prompt|elicitation_dialog|agent_idle" - ); - assert!(settings["hooks"]["Stop"][0]["command"] - .as_str() - .unwrap() - .contains(COPILOT_HOOK_INSTALL_NAME)); - assert!(settings["hooks"]["agentStop"][0]["command"] + assert!(settings["hooks"]["SessionStart"][0][direct_command_field()] .as_str() .unwrap() .contains(COPILOT_HOOK_INSTALL_NAME)); + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + if let Some(entries) = settings["hooks"].get(event) { + assert!( + !entries.to_string().contains(COPILOT_HOOK_INSTALL_NAME), + "expected herdr hooks.{event} entries to be removed" + ); + } + } + assert!(settings["hooks"].get("sessionStart").is_none()); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } #[test] - fn copilot_v1_integration_status_is_current() { + fn copilot_v1_integration_status_is_outdated() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -4550,8 +4633,8 @@ mod tests { assert_eq!(copilot.path, hook_path); assert_eq!(copilot.installed_version, Some(1)); - assert_eq!(copilot.expected_version, 1); - assert_eq!(copilot.state, IntegrationStatusKind::Current); + assert_eq!(copilot.expected_version, COPILOT_INTEGRATION_VERSION); + assert_eq!(copilot.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -4580,22 +4663,13 @@ mod tests { settings["hooks"]["SessionStart"].as_array().unwrap().len(), 1 ); - assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); - assert_eq!( - settings["hooks"]["PostToolUse"].as_array().unwrap().len(), - 1 - ); - assert_eq!( - settings["hooks"]["PostToolUseFailure"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!( - settings["hooks"]["notification"].as_array().unwrap().len(), - 1 - ); + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + assert!( + settings["hooks"].get(event).is_none(), + "expected hooks.{event} to be absent" + ); + } + assert!(settings["hooks"].get("sessionStart").is_none()); clear_integration_path_env(); let _ = fs::remove_dir_all(base); @@ -4618,14 +4692,14 @@ mod tests { let settings = serde_json::json!({ "hooks": { "PreToolUse": [ - {"type": "command", "command": command, "timeoutSec": 10}, + {"type": "command", direct_command_field(): command, "timeoutSec": 10}, {"type": "command", "command": "echo keep", "timeoutSec": 10} ], - "PostToolUse": [{"type": "command", "command": command, "timeoutSec": 10}], + "PostToolUse": [{"type": "command", direct_command_field(): command, "timeoutSec": 10}], "notification": [{ "type": "command", "matcher": "permission_prompt|elicitation_dialog|agent_idle", - "command": command, + direct_command_field(): command, "timeoutSec": 10 }] } @@ -4721,6 +4795,15 @@ mod tests { assert!(settings["hooks"]["SessionStart"][0] .get("matcher") .is_none()); + for (event, action) in DROID_HOOK_EVENTS { + let command = settings["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert!( + command.contains(DROID_HOOK_INSTALL_NAME) && command.ends_with(action), + "expected droid {event} hook command to end with {action}, got {command}" + ); + } assert_eq!(legacy_hooks["hooks"]["PreToolUse"][0]["matcher"], "Read"); assert!(legacy_hooks["hooks"].get("SessionStart").is_none()); @@ -4743,17 +4826,20 @@ mod tests { let settings: Value = serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap()) .unwrap(); - assert_eq!( - settings["hooks"]["SessionStart"].as_array().unwrap().len(), - 1 - ); + for (event, _) in DROID_HOOK_EVENTS { + assert_eq!( + settings["hooks"][event].as_array().unwrap().len(), + 1, + "expected hooks.{event} to be idempotent" + ); + } std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } #[test] - fn droid_v1_integration_status_is_current() { + fn droid_v1_integration_status_is_outdated() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -4775,8 +4861,8 @@ mod tests { assert_eq!(droid.path, hook_path); assert_eq!(droid.installed_version, Some(1)); - assert_eq!(droid.expected_version, 1); - assert_eq!(droid.state, IntegrationStatusKind::Current); + assert_eq!(droid.expected_version, DROID_INTEGRATION_VERSION); + assert_eq!(droid.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -4925,6 +5011,67 @@ mod tests { } #[test] + fn install_kilo_writes_plugin_to_plugin_dir() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let kilo_dir = home.join(".config/kilo"); + fs::create_dir_all(&kilo_dir).unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_kilo().unwrap(); + let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap(); + + assert_eq!( + installed.plugin_path, + kilo_dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME) + ); + assert_eq!(plugin_content, KILO_PLUGIN_ASSET); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_kilo_removes_plugin_when_present() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let kilo_plugin_dir = home.join(".config/kilo/plugin"); + fs::create_dir_all(&kilo_plugin_dir).unwrap(); + fs::write( + kilo_plugin_dir.join(KILO_PLUGIN_INSTALL_NAME), + KILO_PLUGIN_ASSET, + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let result = uninstall_kilo().unwrap(); + + assert!(result.removed_plugin); + assert!(!result.plugin_path.exists()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_kilo_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + std::env::set_var("HOME", &home); + + let err = install_kilo().unwrap_err().to_string(); + + assert!(err.contains("kilo config directory not found")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_hermes_writes_plugin_and_enables_it() { let _lock = integration_env_lock(); let base = unique_base(); @@ -5216,34 +5363,37 @@ mod tests { assert!(!CODEX_HOOK_ASSET.contains("\"state\": action")); assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent")); assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\"")); - assert!(KIMI_HOOK_ASSET.contains("pane.report_agent")); - assert!(KIMI_HOOK_ASSET.contains("pane.release_agent")); - assert!(!KIMI_HOOK_ASSET.contains("agent_session_id")); + assert!(KIMI_HOOK_ASSET.contains("agent_session_id")); + assert!(KIMI_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!KIMI_HOOK_ASSET.contains("\"state\": action")); + assert!(!KIMI_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!(DROID_HOOK_ASSET.contains("hook_event_name")); - assert!(DROID_HOOK_ASSET.contains("SessionStart")); + assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!COPILOT_HOOK_ASSET.contains("\"state\":")); + assert!(!COPILOT_HOOK_ASSET.contains("pane.release_agent")); assert!(DROID_HOOK_ASSET.contains("agent_session_id")); assert!(DROID_HOOK_ASSET.contains("pane.report_agent_session")); - assert!(!DROID_HOOK_ASSET.contains("\"state\":")); + assert!(!DROID_HOOK_ASSET.contains("\"state\": action")); assert!(!DROID_HOOK_ASSET.contains("pane.release_agent")); assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID")); - assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID")); + assert!(OPENCODE_PLUGIN_ASSET.contains("params.agent_session_id = sessionID")); assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session")); - assert!(!OPENCODE_PLUGIN_ASSET.contains("reportState")); - assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent")); + assert!(OPENCODE_PLUGIN_ASSET.contains("reportState")); + assert!(OPENCODE_PLUGIN_ASSET.contains("pane.release_agent")); + assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\"")); + assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\"")); + assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session")); + assert!(KILO_PLUGIN_ASSET.contains("reportState")); + assert!(KILO_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. assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")); - assert!(QODERCLI_HOOK_ASSET.contains("hook_event_name")); assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id")); + assert!(QODERCLI_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!QODERCLI_HOOK_ASSET.contains("\"state\": action")); + assert!(!QODERCLI_HOOK_ASSET.contains("pane.release_agent")); assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT")); assert!(CURSOR_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=cursor")); assert!(CURSOR_HOOK_ASSET.contains("conversation_id")); @@ -5285,18 +5435,16 @@ mod tests { .get("hooks") .and_then(Value::as_object) .expect("hooks should be present"); - for event in [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "Stop", - "SessionEnd", - ] { + for (event, action) in QODERCLI_HOOK_EVENTS { assert!( hooks.contains_key(event), "expected hooks.{event} to be registered" ); + let command = hooks[event][0]["hooks"][0]["command"].as_str().unwrap(); + assert!( + command.contains(QODERCLI_HOOK_INSTALL_NAME) && command.ends_with(action), + "expected qodercli {event} hook command to end with {action}, got {command}" + ); } // Pre-existing settings keys must be preserved. assert!(settings.get("permissions").is_some()); @@ -5320,14 +5468,7 @@ mod tests { serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) .unwrap(); let hooks = settings.get("hooks").and_then(Value::as_object).unwrap(); - for event in [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "Stop", - "SessionEnd", - ] { + for (event, _) in QODERCLI_HOOK_EVENTS { let entries = hooks.get(event).and_then(Value::as_array).unwrap(); assert_eq!( entries.len(), @@ -5353,7 +5494,7 @@ mod tests { let mut settings: Value = serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) .unwrap(); - settings["hooks"]["UserPromptSubmit"] + settings["hooks"]["SessionStart"] .as_array_mut() .unwrap() .push(json!({ @@ -5374,10 +5515,7 @@ mod tests { serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) .unwrap(); let hooks = settings.get("hooks").and_then(Value::as_object).unwrap(); - let remaining = hooks - .get("UserPromptSubmit") - .and_then(Value::as_array) - .unwrap(); + let remaining = hooks.get("SessionStart").and_then(Value::as_array).unwrap(); assert_eq!(remaining.len(), 1); let cmd = remaining[0]["hooks"][0]["command"].as_str().unwrap(); assert_eq!(cmd, "echo user-defined"); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index afd94ecf..74b7973d 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1539,10 +1539,10 @@ mod tests { fn fallback_idle_does_not_override_full_lifecycle_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Kimi), AgentState::Working); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); terminal.set_hook_authority_with_custom_status_at( - "herdr:kimi".into(), - "kimi".into(), + "herdr:opencode".into(), + "opencode".into(), AgentState::Working, None, Some("thinking".into()), @@ -1551,7 +1551,7 @@ mod tests { now, ); terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Kimi), + Some(Agent::OpenCode), AgentState::Idle, false, true, @@ -1634,10 +1634,10 @@ mod tests { fn pty_working_fallback_is_ignored_under_full_lifecycle_hook_authority() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Qodercli), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( - "herdr:qodercli".into(), - "qodercli".into(), + "herdr:kilo".into(), + "kilo".into(), AgentState::Idle, None, None, @@ -1647,7 +1647,7 @@ mod tests { ); let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Qodercli), + Some(Agent::Kilo), AgentState::Working, false, false, @@ -1881,10 +1881,10 @@ mod tests { #[test] fn detected_agent_clear_clears_matching_hook_authority() { let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Cursor), AgentState::Idle); terminal.set_hook_authority( - "herdr:opencode".into(), - "opencode".into(), + "herdr:cursor".into(), + "cursor".into(), AgentState::Idle, None, None, @@ -2287,20 +2287,20 @@ mod tests { let mut terminal = test_terminal(); terminal .set_hook_authority_with_session_ref( - "herdr:copilot".into(), - "copilot".into(), + "herdr:opencode".into(), + "opencode".into(), AgentState::Working, None, None, - crate::agent_resume::AgentSessionRef::id("copilot-session"), + crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal .set_hook_authority_with_session_ref( - "herdr:copilot".into(), - "copilot".into(), + "herdr:opencode".into(), + "opencode".into(), AgentState::Blocked, Some("needs approval".into()), None, @@ -2317,7 +2317,7 @@ mod tests { .as_ref() .and_then(|authority| authority.session_ref.as_ref()) .map(|session_ref| session_ref.value.as_str()), - Some("copilot-session") + Some("opencode-session") ); } |
