aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/claude/herdr-agent-state.ps129
-rw-r--r--src/integration/assets/codex/herdr-agent-state.ps127
-rw-r--r--src/integration/assets/copilot/herdr-agent-state.ps1104
-rw-r--r--src/integration/assets/droid/herdr-agent-state.ps126
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.ps122
-rw-r--r--src/integration/assets/qodercli/herdr-agent-state.ps135
-rw-r--r--src/integration/mod.rs526
7 files changed, 540 insertions, 229 deletions
diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1
new file mode 100644
index 00000000..71417e9c
--- /dev/null
+++ b/src/integration/assets/claude/herdr-agent-state.ps1
@@ -0,0 +1,29 @@
+# installed by herdr
+# 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=5
+
+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 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ exit 0
+}
+
+if ($payload.hook_event_name -eq "SubagentStop") { exit 0 }
+
+$sessionId = $payload.session_id
+if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
+
+$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
+try {
+ & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:claude --agent claude --seq $seq --agent-session-id $sessionId 2>$null | Out-Null
+} catch {
+}
diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/src/integration/assets/codex/herdr-agent-state.ps1
new file mode 100644
index 00000000..e56948f5
--- /dev/null
+++ b/src/integration/assets/codex/herdr-agent-state.ps1
@@ -0,0 +1,27 @@
+# installed by herdr
+# 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=5
+
+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 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ exit 0
+}
+
+$sessionId = $payload.session_id
+if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
+
+$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
+try {
+ & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:codex --agent codex --seq $seq --agent-session-id $sessionId 2>$null | Out-Null
+} catch {
+}
diff --git a/src/integration/assets/copilot/herdr-agent-state.ps1 b/src/integration/assets/copilot/herdr-agent-state.ps1
new file mode 100644
index 00000000..61cec0c9
--- /dev/null
+++ b/src/integration/assets/copilot/herdr-agent-state.ps1
@@ -0,0 +1,104 @@
+# installed by herdr
+# 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
+
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { @{} } else { $inputText | ConvertFrom-Json }
+} catch {
+ $payload = @{}
+}
+
+function First-Text {
+ param([object[]]$Names)
+ foreach ($name in $Names) {
+ $value = $payload.$name
+ if ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) {
+ return $value
+ }
+ }
+ return $null
+}
+
+function Normalize-Event {
+ param([string]$Event)
+ if ([string]::IsNullOrWhiteSpace($Event)) { return "" }
+ 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 ""
+}
+
+function Has-Initial-Prompt {
+ $value = $payload.initial_prompt
+ if ($null -eq $value) { $value = $payload.initialPrompt }
+ return ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value))
+}
+
+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 {
+}
diff --git a/src/integration/assets/droid/herdr-agent-state.ps1 b/src/integration/assets/droid/herdr-agent-state.ps1
new file mode 100644
index 00000000..ad035d72
--- /dev/null
+++ b/src/integration/assets/droid/herdr-agent-state.ps1
@@ -0,0 +1,26 @@
+# installed by herdr
+# 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
+
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ exit 0
+}
+
+if ($payload.hook_event_name -ne "SessionStart") { exit 0 }
+
+$sessionId = $payload.session_id
+if ([string]::IsNullOrWhiteSpace($sessionId)) { 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
+} catch {
+}
diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1
new file mode 100644
index 00000000..a230a627
--- /dev/null
+++ b/src/integration/assets/kimi/herdr-agent-state.ps1
@@ -0,0 +1,22 @@
+# installed by herdr
+# 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
+
+param([string]$Action = "")
+
+if ($Action -notin @("working", "idle", "blocked", "release")) { exit 0 }
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+
+[Console]::In.ReadToEnd() | Out-Null
+$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
+ }
+} catch {
+}
diff --git a/src/integration/assets/qodercli/herdr-agent-state.ps1 b/src/integration/assets/qodercli/herdr-agent-state.ps1
new file mode 100644
index 00000000..f7102817
--- /dev/null
+++ b/src/integration/assets/qodercli/herdr-agent-state.ps1
@@ -0,0 +1,35 @@
+# installed by herdr
+# 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
+
+param([string]$Action = "")
+
+if ($Action -notin @("working", "idle", "blocked", "release")) { exit 0 }
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ $payload = $null
+}
+
+if ($payload.hook_event_name -eq "SubagentStop") { exit 0 }
+if ($payload.agent_id -and $Action -in @("idle", "release")) { 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
+ }
+} catch {
+}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 2989ba90..abeb51e7 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -17,16 +17,40 @@ const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts";
const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts");
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_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/claude/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/claude/herdr-agent-state.sh")
+};
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_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const CODEX_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/codex/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/codex/herdr-agent-state.sh")
+};
const CODEX_INTEGRATION_VERSION: u32 = 5;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
-const KIMI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
-const KIMI_HOOK_ASSET: &str = include_str!("assets/kimi/herdr-agent-state.sh");
+const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const KIMI_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/kimi/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/kimi/herdr-agent-state.sh")
+};
const KIMI_INTEGRATION_VERSION: u32 = 1;
const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME";
const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration";
@@ -44,12 +68,28 @@ const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [
("StopFailure", "idle"),
("SessionEnd", "release"),
];
-const COPILOT_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
-const COPILOT_HOOK_ASSET: &str = include_str!("assets/copilot/herdr-agent-state.sh");
+const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const COPILOT_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/copilot/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/copilot/herdr-agent-state.sh")
+};
const COPILOT_INTEGRATION_VERSION: u32 = 1;
const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME";
-const DROID_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
-const DROID_HOOK_ASSET: &str = include_str!("assets/droid/herdr-agent-state.sh");
+const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const DROID_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/droid/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/droid/herdr-agent-state.sh")
+};
const DROID_INTEGRATION_VERSION: u32 = 1;
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
@@ -60,8 +100,16 @@ const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml");
const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py");
const HERMES_INTEGRATION_VERSION: u32 = 2;
-const QODERCLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
-const QODERCLI_HOOK_ASSET: &str = include_str!("assets/qodercli/herdr-agent-state.sh");
+const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/qodercli/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/qodercli/herdr-agent-state.sh")
+};
const QODERCLI_INTEGRATION_VERSION: u32 = 1;
const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR";
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
@@ -1074,55 +1122,24 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
"claude settings",
"claude settings hooks",
)?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- remove_command_hook(
- hooks,
- "PostToolUse",
- &format!("bash {quoted_hook_path} working"),
- )?;
- remove_command_hook(
- hooks,
- "PostToolUseFailure",
- &format!("bash {quoted_hook_path} working"),
- )?;
- remove_command_hook(
- hooks,
- "SubagentStop",
- &format!("bash {quoted_hook_path} working"),
- )?;
- remove_command_hook(
- hooks,
- "PermissionRequest",
- &format!("bash {quoted_hook_path} blocked"),
- )?;
- remove_command_hook(
- hooks,
- "SessionStart",
- &format!("bash {quoted_hook_path} idle"),
- )?;
- remove_command_hook(
- hooks,
- "UserPromptSubmit",
- &format!("bash {quoted_hook_path} working"),
- )?;
- remove_command_hook(
- hooks,
- "PreToolUse",
- &format!("bash {quoted_hook_path} working"),
- )?;
- remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
- remove_command_hook(
- hooks,
- "SessionEnd",
- &format!("bash {quoted_hook_path} release"),
- )?;
+ remove_hook_commands(hooks, "PostToolUse", &hook_path, Some("working"))?;
+ remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?;
+ remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?;
+ remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
+ 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, "Stop", &hook_path, Some("idle"))?;
+ remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?;
+ remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
ensure_command_hook(
hooks,
"SessionStart",
- format!("bash {quoted_hook_path} session"),
+ hook_command(&hook_path, Some("session")),
10,
Some("*"),
)?;
+ remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
@@ -1160,35 +1177,20 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
"codex hooks file",
"codex hooks file hooks",
)?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- remove_command_hook(
- hooks,
- "PermissionRequest",
- &format!("bash {quoted_hook_path} blocked"),
- )?;
- remove_command_hook(
- hooks,
- "SessionStart",
- &format!("bash {quoted_hook_path} idle"),
- )?;
- remove_command_hook(
- hooks,
- "UserPromptSubmit",
- &format!("bash {quoted_hook_path} working"),
- )?;
- remove_command_hook(
- hooks,
- "PreToolUse",
- &format!("bash {quoted_hook_path} working"),
- )?;
- remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
+ remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
+ 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, "Stop", &hook_path, Some("idle"))?;
+ remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
ensure_command_hook(
hooks,
"SessionStart",
- format!("bash {quoted_hook_path} session"),
+ hook_command(&hook_path, Some("session")),
10,
None,
)?;
+ remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
@@ -1236,6 +1238,7 @@ pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> {
if new_config != existing_config {
fs::write(&config_path, new_config)?;
}
+ remove_legacy_bash_hook_file(&hook_path)?;
Ok(KimiInstallPaths {
hook_path,
@@ -1277,10 +1280,20 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
"copilot settings",
"copilot settings hooks",
)?;
- let command = format!(
- "bash {}",
- shell_single_quote(&hook_path.display().to_string())
- );
+ let command = hook_command(&hook_path, None);
+ for event in [
+ "SessionStart",
+ "UserPromptSubmit",
+ "PreToolUse",
+ "PostToolUse",
+ "PostToolUseFailure",
+ "Stop",
+ "agentStop",
+ "SessionEnd",
+ "notification",
+ ] {
+ 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)?;
@@ -1296,6 +1309,7 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
10,
Some("permission_prompt|elicitation_dialog|agent_idle"),
)?;
+ remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
@@ -1339,15 +1353,15 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
"droid settings",
"droid settings hooks",
)?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?;
+ remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
ensure_command_hook(
hooks,
"SessionStart",
- format!("bash {quoted_hook_path}"),
+ hook_command(&hook_path, None),
10,
None,
)?;
+ remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
@@ -1364,8 +1378,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
"droid hooks file",
"droid hooks file hooks",
)? {
- updated_legacy_hooks =
- remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?;
+ updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
}
if updated_legacy_hooks {
fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
@@ -1475,54 +1488,25 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
"claude settings",
"claude settings hooks",
)? {
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- updated_settings |= remove_command_hook(
- hooks,
- "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",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "PreToolUse",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "PermissionRequest",
- &format!("bash {quoted_hook_path} blocked"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "PostToolUse",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "PostToolUseFailure",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "SubagentStop",
- &format!("bash {quoted_hook_path} working"),
- )?;
updated_settings |=
- remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
- updated_settings |= remove_command_hook(
- hooks,
- "SessionEnd",
- &format!("bash {quoted_hook_path} release"),
- )?;
+ remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?;
+ updated_settings |=
+ remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
+ 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, "PostToolUse", &hook_path, Some("working"))?;
+ updated_settings |=
+ remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?;
+ updated_settings |=
+ remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?;
+ updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
+ updated_settings |=
+ remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?;
}
if updated_settings {
@@ -1530,7 +1514,8 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
}
}
- let removed_hook_file = remove_file_if_exists(&hook_path)?;
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
Ok(ClaudeUninstallResult {
hook_path,
@@ -1559,34 +1544,16 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
"codex hooks file",
"codex hooks file hooks",
)? {
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- updated_hooks |= remove_command_hook(
- hooks,
- "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",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_hooks |= remove_command_hook(
- hooks,
- "PreToolUse",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_hooks |= remove_command_hook(
- hooks,
- "PermissionRequest",
- &format!("bash {quoted_hook_path} blocked"),
- )?;
+ updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?;
+ updated_hooks |=
+ remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
+ updated_hooks |=
+ remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?;
+ updated_hooks |=
+ remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?;
updated_hooks |=
- remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
+ remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
+ updated_hooks |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
}
if updated_hooks {
@@ -1594,7 +1561,8 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
}
}
- let removed_hook_file = remove_file_if_exists(&hook_path)?;
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
Ok(CodexUninstallResult {
hook_path,
@@ -1620,7 +1588,8 @@ pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> {
}
}
- let removed_hook_file = remove_file_if_exists(&hook_path)?;
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
Ok(KimiUninstallResult {
hook_path,
@@ -1651,19 +1620,20 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
"copilot settings",
"copilot settings hooks",
)? {
- let command = format!(
- "bash {}",
- shell_single_quote(&hook_path.display().to_string())
- );
- updated_settings |= remove_direct_command_hook(hooks, "SessionStart", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "UserPromptSubmit", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "PreToolUse", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "PostToolUse", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "PostToolUseFailure", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "Stop", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "agentStop", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "SessionEnd", &command)?;
- updated_settings |= remove_direct_command_hook(hooks, "notification", &command)?;
+ 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)?;
}
if updated_settings {
@@ -1671,7 +1641,8 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
}
}
- let removed_hook_file = remove_file_if_exists(&hook_path)?;
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
Ok(CopilotUninstallResult {
hook_path,
@@ -1688,8 +1659,6 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
let settings_path = droid_dir.join("settings.json");
let mut updated_hooks = false;
let mut updated_settings = false;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
-
if hooks_path.is_file() {
let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
.map_err(|err| {
@@ -1702,8 +1671,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
"droid hooks file",
"droid hooks file hooks",
)? {
- updated_hooks |=
- remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?;
+ updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
}
if updated_hooks {
@@ -1725,8 +1693,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
"droid settings",
"droid settings hooks",
)? {
- updated_settings =
- remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?;
+ updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
}
if updated_settings {
@@ -1734,7 +1701,8 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
}
}
- let removed_hook_file = remove_file_if_exists(&hook_path)?;
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
Ok(DroidUninstallResult {
hook_path,
@@ -1823,53 +1791,58 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
"qodercli settings",
"qodercli settings hooks",
)?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
-
// 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",
- format!("bash {quoted_hook_path} idle"),
+ hook_command(&hook_path, Some("idle")),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"UserPromptSubmit",
- format!("bash {quoted_hook_path} working"),
+ hook_command(&hook_path, Some("working")),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"PreToolUse",
- format!("bash {quoted_hook_path} working"),
+ hook_command(&hook_path, Some("working")),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"PermissionRequest",
- format!("bash {quoted_hook_path} blocked"),
+ hook_command(&hook_path, Some("blocked")),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"Stop",
- format!("bash {quoted_hook_path} idle"),
+ hook_command(&hook_path, Some("idle")),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"SessionEnd",
- format!("bash {quoted_hook_path} release"),
+ hook_command(&hook_path, Some("release")),
10,
Some("*"),
)?;
+ remove_legacy_bash_hook_file(&hook_path)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
@@ -1901,34 +1874,17 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
"qodercli settings",
"qodercli settings hooks",
)? {
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- updated_settings |= remove_command_hook(
- hooks,
- "SessionStart",
- &format!("bash {quoted_hook_path} idle"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "UserPromptSubmit",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "PreToolUse",
- &format!("bash {quoted_hook_path} working"),
- )?;
- updated_settings |= remove_command_hook(
- hooks,
- "PermissionRequest",
- &format!("bash {quoted_hook_path} blocked"),
- )?;
updated_settings |=
- remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
- updated_settings |= remove_command_hook(
- hooks,
- "SessionEnd",
- &format!("bash {quoted_hook_path} release"),
- )?;
+ 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"))?;
}
if updated_settings {
@@ -1936,7 +1892,8 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
}
}
- let removed_hook_file = remove_file_if_exists(&hook_path)?;
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
Ok(QodercliUninstallResult {
hook_path,
@@ -2156,6 +2113,53 @@ fn remove_direct_command_hook(
Ok(removed)
}
+fn remove_hook_commands(
+ hooks: &mut Map<String, Value>,
+ event: &str,
+ hook_path: &Path,
+ action: Option<&str>,
+) -> io::Result<bool> {
+ let mut removed = false;
+ for command in hook_command_variants(hook_path, action) {
+ removed |= remove_command_hook(hooks, event, &command)?;
+ }
+ Ok(removed)
+}
+
+fn remove_direct_hook_commands(
+ hooks: &mut Map<String, Value>,
+ event: &str,
+ hook_path: &Path,
+ action: Option<&str>,
+) -> io::Result<bool> {
+ let mut removed = false;
+ for command in hook_command_variants(hook_path, action) {
+ removed |= remove_direct_command_hook(hooks, event, &command)?;
+ }
+ Ok(removed)
+}
+
+fn hook_command_variants(hook_path: &Path, action: Option<&str>) -> Vec<String> {
+ let mut commands = vec![hook_command(hook_path, action)];
+ push_unique_command(&mut commands, legacy_bash_hook_command(hook_path, action));
+
+ #[cfg(windows)]
+ {
+ push_unique_command(
+ &mut commands,
+ legacy_bash_hook_command(&legacy_bash_hook_path(hook_path), action),
+ );
+ }
+
+ commands
+}
+
+fn push_unique_command(commands: &mut Vec<String>, command: String) {
+ if !commands.iter().any(|existing| existing == &command) {
+ commands.push(command);
+ }
+}
+
fn is_matching_command_hook(hook: &Value, command: &str) -> bool {
hook.get("type").and_then(Value::as_str) == Some("command")
&& hook.get("command").and_then(Value::as_str) == Some(command)
@@ -2169,6 +2173,33 @@ fn remove_file_if_exists(path: &Path) -> io::Result<bool> {
}
}
+#[cfg(windows)]
+fn legacy_bash_hook_path(hook_path: &Path) -> PathBuf {
+ hook_path.with_file_name("herdr-agent-state.sh")
+}
+
+#[cfg(windows)]
+fn remove_legacy_bash_hook_file(hook_path: &Path) -> io::Result<bool> {
+ let legacy_path = legacy_bash_hook_path(hook_path);
+ let content = match fs::read_to_string(&legacy_path) {
+ Ok(content) => content,
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
+ Err(err) => return Err(err),
+ };
+
+ if content.contains("HERDR_INTEGRATION_ID=") {
+ fs::remove_file(legacy_path)?;
+ return Ok(true);
+ }
+
+ Ok(false)
+}
+
+#[cfg(not(windows))]
+fn remove_legacy_bash_hook_file(_hook_path: &Path) -> io::Result<bool> {
+ Ok(false)
+}
+
fn remove_dir_all_if_exists(path: &Path) -> io::Result<bool> {
match fs::remove_dir_all(path) {
Ok(()) => Ok(true),
@@ -2540,10 +2571,7 @@ fn build_kimi_config_with_hooks(content: &str, hook_path: &Path) -> String {
}
fn kimi_hook_table(event: &str, hook_path: &Path, action: &str) -> String {
- let command = format!(
- "bash {} {action}",
- shell_single_quote(&hook_path.display().to_string())
- );
+ let command = hook_command(hook_path, Some(action));
format!(
"[[hooks]]\nevent = {}\ncommand = {}\ntimeout = 10\n\n",
toml_basic_string(event),
@@ -2650,6 +2678,49 @@ fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
+fn hook_command(hook_path: &Path, action: Option<&str>) -> String {
+ let path = hook_path.display().to_string();
+ #[cfg(windows)]
+ {
+ let mut command = format!(
+ "powershell -NoProfile -ExecutionPolicy Bypass -File {}",
+ windows_command_quote(&path)
+ );
+ if let Some(action) = action {
+ command.push(' ');
+ command.push_str(action);
+ }
+ command
+ }
+
+ #[cfg(not(windows))]
+ {
+ let mut command = format!("bash {}", shell_single_quote(&path));
+ if let Some(action) = action {
+ command.push(' ');
+ command.push_str(action);
+ }
+ command
+ }
+}
+
+fn legacy_bash_hook_command(hook_path: &Path, action: Option<&str>) -> String {
+ let mut command = format!(
+ "bash {}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
+ if let Some(action) = action {
+ command.push(' ');
+ command.push_str(action);
+ }
+ command
+}
+
+#[cfg(windows)]
+fn windows_command_quote(value: &str) -> String {
+ format!("\"{}\"", value.replace('"', "\\\""))
+}
+
fn make_executable(_path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
@@ -2795,10 +2866,7 @@ mod tests {
}
fn kimi_hook_command(hook_path: &Path, action: &str) -> String {
- format!(
- "bash {} {action}",
- shell_single_quote(&hook_path.display().to_string())
- )
+ hook_command(hook_path, Some(action))
}
fn kimi_config_hooks(config: &str) -> Vec<toml::Value> {