aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli/pane.rs164
-rw-r--r--src/client/input.rs27
-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
-rw-r--r--src/worktree.rs23
10 files changed, 748 insertions, 235 deletions
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 3fb83248..08d84cf9 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -1,9 +1,9 @@
use crate::api::schema::{
Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams,
- PaneListParams, PaneNeighborParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams,
- PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams,
- PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams,
- ReadFormat, ReadSource, Request,
+ PaneListParams, PaneNeighborParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams,
+ PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams,
+ PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
+ PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@@ -29,6 +29,8 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
"send-text" => pane_send_text(&args[1..]),
"send-keys" => pane_send_keys(&args[1..]),
"report-agent" => pane_report_agent(&args[1..]),
+ "report-agent-session" => pane_report_agent_session(&args[1..]),
+ "release-agent" => pane_release_agent(&args[1..]),
"report-metadata" => pane_report_metadata(&args[1..]),
"run" => pane_run(&args[1..]),
"help" | "--help" | "-h" => {
@@ -831,6 +833,158 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
}))
}
+fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
+ return Ok(2);
+ };
+
+ let pane_id = super::normalize_pane_id(raw_pane_id);
+ let mut source = None;
+ let mut agent = None;
+ let mut seq = None;
+ let mut agent_session_id = None;
+ let mut agent_session_path = None;
+
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--source" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --source");
+ return Ok(2);
+ };
+ source = Some(value.clone());
+ index += 2;
+ }
+ "--agent" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --agent");
+ return Ok(2);
+ };
+ agent = Some(value.clone());
+ index += 2;
+ }
+ "--seq" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --seq");
+ return Ok(2);
+ };
+ seq = Some(super::parse_u64_flag("--seq", value)?);
+ index += 2;
+ }
+ "--agent-session-id" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --agent-session-id");
+ return Ok(2);
+ };
+ agent_session_id = Some(value.clone());
+ index += 2;
+ }
+ "--agent-session-path" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --agent-session-path");
+ return Ok(2);
+ };
+ agent_session_path = Some(value.clone());
+ index += 2;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ let Some(source) = source.and_then(|source| {
+ let source = source.trim().to_string();
+ (!source.is_empty()).then_some(source)
+ }) else {
+ eprintln!("missing required --source");
+ return Ok(2);
+ };
+ let Some(agent) = agent else {
+ eprintln!("missing required --agent");
+ return Ok(2);
+ };
+
+ super::send_ok_request(Method::PaneReportAgentSession(
+ PaneReportAgentSessionParams {
+ pane_id,
+ source,
+ agent,
+ seq,
+ agent_session_id,
+ agent_session_path,
+ },
+ ))
+}
+
+fn pane_release_agent(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]");
+ return Ok(2);
+ };
+
+ let pane_id = super::normalize_pane_id(raw_pane_id);
+ let mut source = None;
+ let mut agent = None;
+ let mut seq = None;
+
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--source" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --source");
+ return Ok(2);
+ };
+ source = Some(value.clone());
+ index += 2;
+ }
+ "--agent" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --agent");
+ return Ok(2);
+ };
+ agent = Some(value.clone());
+ index += 2;
+ }
+ "--seq" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --seq");
+ return Ok(2);
+ };
+ seq = Some(super::parse_u64_flag("--seq", value)?);
+ index += 2;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ let Some(source) = source.and_then(|source| {
+ let source = source.trim().to_string();
+ (!source.is_empty()).then_some(source)
+ }) else {
+ eprintln!("missing required --source");
+ return Ok(2);
+ };
+ let Some(agent) = agent else {
+ eprintln!("missing required --agent");
+ return Ok(2);
+ };
+
+ super::send_ok_request(Method::PaneReleaseAgent(PaneReleaseAgentParams {
+ pane_id,
+ source,
+ agent,
+ seq,
+ }))
+}
+
fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
eprintln!("usage: herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]");
@@ -1038,6 +1192,8 @@ fn print_pane_help() {
eprintln!(" herdr pane send-text <pane_id> <text>");
eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]");
eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
+ eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
+ eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]");
eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]");
eprintln!(" herdr pane run <pane_id> <command>");
}
diff --git a/src/client/input.rs b/src/client/input.rs
index bbc37a58..b89a57b1 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -180,6 +180,15 @@ fn windows_key_raw_bytes(
match key.code {
KeyCode::Esc if key.modifiers.is_empty() => Some(vec![0x1b]),
+ KeyCode::Char(ch)
+ if !raw_sequence_pending
+ && matches!(ch, 'i' | 'I')
+ && key.modifiers.contains(KeyModifiers::CONTROL)
+ && !key.modifiers.contains(KeyModifiers::ALT) =>
+ {
+ let mut buf = [0; 4];
+ Some(ch.encode_utf8(&mut buf).as_bytes().to_vec())
+ }
KeyCode::Char(ch) if raw_sequence_pending || ch.is_control() => {
let mut bytes = Vec::new();
if key.modifiers.contains(KeyModifiers::ALT) {
@@ -357,6 +366,24 @@ mod windows_tests {
}
#[test]
+ fn windows_pasted_printable_ctrl_i_routes_as_literal_i() {
+ let event = Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL));
+ assert_eq!(
+ windows_key_raw_bytes(&event, false).as_deref(),
+ Some(b"i".as_slice())
+ );
+
+ let event = Event::Key(KeyEvent::new(
+ KeyCode::Char('I'),
+ KeyModifiers::CONTROL | KeyModifiers::SHIFT,
+ ));
+ assert_eq!(
+ windows_key_raw_bytes(&event, false).as_deref(),
+ Some(b"I".as_slice())
+ );
+ }
+
+ #[test]
fn windows_eot_control_char_normalizes_to_ctrl_d() {
let event = Event::Key(KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::empty()));
let bytes = windows_key_raw_bytes(&event, false).expect("eot routes through raw framer");
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> {
diff --git a/src/worktree.rs b/src/worktree.rs
index 29eb7647..3f8a017f 100644
--- a/src/worktree.rs
+++ b/src/worktree.rs
@@ -68,13 +68,23 @@ pub(crate) fn expand_tilde_path(path: &str) -> PathBuf {
});
if let Some(rest) = tilde_rest {
return home_dir_from_env(cfg!(windows), |key| std::env::var_os(key))
- .map(|home| home.join(rest))
+ .map(|home| join_tilde_rest(home, rest))
.unwrap_or_else(|_| PathBuf::from(path));
}
PathBuf::from(path)
}
+fn join_tilde_rest(home: PathBuf, rest: &str) -> PathBuf {
+ if cfg!(windows) {
+ rest.split(['/', '\\'])
+ .filter(|component| !component.is_empty())
+ .fold(home, |path, component| path.join(component))
+ } else {
+ home.join(rest)
+ }
+}
+
fn home_dir_from_env(
is_windows: bool,
env: impl Fn(&str) -> Option<OsString>,
@@ -519,11 +529,20 @@ prunable stale
#[cfg(windows)]
#[test]
- fn windows_tilde_expansion_accepts_windows_separator() {
+ fn windows_tilde_expansion_normalizes_separators() {
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
std::env::set_var("HOME", "~");
std::env::set_var("USERPROFILE", r"C:\Users\herdr");
+ let default_path = expand_tilde_path("~/.herdr/worktrees");
+ assert_eq!(
+ default_path,
+ PathBuf::from(r"C:\Users\herdr\.herdr\worktrees")
+ );
+ assert_eq!(
+ default_path.display().to_string(),
+ r"C:\Users\herdr\.herdr\worktrees"
+ );
assert_eq!(
expand_tilde_path(r"~\.herdr\worktrees"),
PathBuf::from(r"C:\Users\herdr\.herdr\worktrees")