aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 15:59:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 15:59:34 +0300
commitdf7880c54b8b5d337421cb4b1dd8daea27008801 (patch)
tree4d0cc6290ad71c413bac71cfaab4ef41ea017ecb /src/integration
parenteeacb45b4b6ab4446c5bea1331f743fee5e5b9f8 (diff)
feat: update agent lifecycle integrations
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/copilot/herdr-agent-state.ps184
-rw-r--r--src/integration/assets/copilot/herdr-agent-state.sh150
-rw-r--r--src/integration/assets/droid/herdr-agent-state.ps114
-rw-r--r--src/integration/assets/droid/herdr-agent-state.sh21
-rw-r--r--src/integration/assets/kilo/herdr-agent-state.js159
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.ps120
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.sh60
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js82
-rw-r--r--src/integration/assets/qodercli/herdr-agent-state.ps117
-rw-r--r--src/integration/assets/qodercli/herdr-agent-state.sh64
-rw-r--r--src/integration/mod.rs580
11 files changed, 724 insertions, 527 deletions
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");